diff --git a/core/src/commands/build.rs b/core/src/commands/build.rs index 78bdea23..428f5455 100644 --- a/core/src/commands/build.rs +++ b/core/src/commands/build.rs @@ -321,10 +321,29 @@ fn do_build_kpar_inner, Pr: ProjectRead>( } } + let readme_source_path = project.project_root().map(|p| p.join("README.md")); + let readme_content = if let Some(readme_path) = &readme_source_path { + match std::fs::read_to_string(readme_path) { + Ok(content) => { + let header = crate::style::get_style_config().header; + let including = "Including"; + log::info!("{header}{including:>12}{header:#} readme from `{readme_path}`"); + Some(content) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + return Err(FsIoError::ReadFile(readme_path.clone(), e).into()); + } + } + } else { + None + }; + Ok(LocalKParProject::from_project( &local_project, path, compression.into(), + readme_content.as_deref(), )?) } diff --git a/core/src/project/local_kpar.rs b/core/src/project/local_kpar.rs index 3826183b..66ed1e02 100644 --- a/core/src/project/local_kpar.rs +++ b/core/src/project/local_kpar.rs @@ -210,6 +210,7 @@ impl LocalKParProject { from: &Pr, path: P, compression: zip::CompressionMethod, + readme: Option<&str>, ) -> Result> { let file = wrapfs::File::create(&path)?; let mut zip = zip::ZipWriter::new(file); @@ -248,6 +249,13 @@ impl LocalKParProject { .map_err(|e| FsIoError::CopyFile(source_path.into(), path.to_path_buf(), e))?; } + if let Some(readme_content) = readme { + zip.start_file("README.md", options) + .map_err(|e| ZipArchiveError::Write(Utf8Path::new("README.md").into(), e))?; + zip.write_all(readme_content.as_bytes()) + .map_err(|e| FsIoError::WriteFile(path.as_ref().into(), e))?; + } + zip.finish() .map_err(|e| ZipArchiveError::Finish(path.as_ref().into(), e))?; diff --git a/core/src/project/local_src.rs b/core/src/project/local_src.rs index 313933a8..fe8b7aff 100644 --- a/core/src/project/local_src.rs +++ b/core/src/project/local_src.rs @@ -361,6 +361,10 @@ pub enum PathError { impl ProjectRead for LocalSrcProject { type Error = LocalSrcError; + fn project_root(&self) -> Option<&Utf8Path> { + Some(&self.project_path) + } + fn get_project( &self, ) -> Result< diff --git a/core/src/project/mod.rs b/core/src/project/mod.rs index 7447215c..b122f894 100644 --- a/core/src/project/mod.rs +++ b/core/src/project/mod.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: © 2025 Sysand contributors // SPDX-License-Identifier: MIT OR Apache-2.0 +use camino::Utf8Path; use futures::io::{AsyncBufReadExt as _, AsyncRead}; use indexmap::IndexMap; use sha2::{Digest, Sha256}; @@ -156,6 +157,11 @@ pub trait ProjectRead { // Optional and helpers + /// Returns the local filesystem root path of this project, if available. + fn project_root(&self) -> Option<&Utf8Path> { + None + } + fn get_info(&self) -> Result, Self::Error> { Ok(self.get_project()?.0) } diff --git a/docs/src/commands/build.md b/docs/src/commands/build.md index 382ddabb..1bd0f80b 100644 --- a/docs/src/commands/build.md +++ b/docs/src/commands/build.md @@ -16,6 +16,9 @@ Creates a KPAR file from the current project. Current project is determined as in [sysand print-root](root.md) and if none is found uses the current directory instead. +If a `README.md` file exist at the project root, it is included in the +`.kpar` archive. + ## Arguments - `[PATH]`: Path for the finished KPAR or KPARs. When building a diff --git a/sysand/tests/cli_build.rs b/sysand/tests/cli_build.rs index 1b31c4fe..6e9b5570 100644 --- a/sysand/tests/cli_build.rs +++ b/sysand/tests/cli_build.rs @@ -506,6 +506,135 @@ fn test_compression_methods() -> Result<(), Box> { Ok(()) } +/// Build a project with a README.md at the project root +#[test] +fn project_build_with_readme() -> Result<(), Box> { + let (_temp_dir, cwd, out) = run_sysand( + ["init", "--version", "1.2.3", "--name", "test_readme"], + None, + )?; + + std::fs::write(cwd.join("test.sysml"), b"package P;\n")?; + std::fs::write(cwd.join("README.md"), b"# My Project\nHello world\n")?; + + out.assert().success(); + + let out = run_sysand_in(&cwd, ["include", "--no-index-symbols", "test.sysml"], None)?; + out.assert().success(); + + let out = run_sysand_in(&cwd, ["build", "./test_build.kpar"], None)?; + out.assert() + .success() + .stderr(predicate::str::contains("Including readme from")); + + // Verify the KPAR contains README.md with correct content + assert_kpar_readme(&cwd.join("test_build.kpar"), "# My Project\nHello world\n"); + + Ok(()) +} + +/// Build a project without any README file — should succeed +#[test] +fn project_build_without_readme() -> Result<(), Box> { + let (_temp_dir, cwd, out) = run_sysand( + ["init", "--version", "1.2.3", "--name", "test_no_readme"], + None, + )?; + + std::fs::write(cwd.join("test.sysml"), b"package P;\n")?; + + out.assert().success(); + + let out = run_sysand_in(&cwd, ["include", "--no-index-symbols", "test.sysml"], None)?; + out.assert().success(); + + let out = run_sysand_in(&cwd, ["build", "./test_build.kpar"], None)?; + out.assert().success(); + + // Verify the KPAR does NOT contain README.md + assert_kpar_no_readme(&cwd.join("test_build.kpar")); + + Ok(()) +} + +/// Build workspace with per-project READMEs +#[test] +fn workspace_build_with_readme() -> Result<(), Box> { + let (_temp_dir, cwd) = new_temp_cwd()?; + let project1_cwd = cwd.join("project1"); + let project2_cwd = cwd.join("project2"); + + std::fs::write( + cwd.join(".workspace.json"), + br#"{"projects": [ + {"path": "project1", "iris": ["urn:kpar:project1"]}, + {"path": "project2", "iris": ["urn:kpar:project2"]} + ]}"#, + )?; + + for (project_cwd, readme_content) in [ + (&project1_cwd, "# Project 1\n"), + (&project2_cwd, "# Project 2\n"), + ] { + std::fs::create_dir(project_cwd)?; + let project_name = project_cwd.file_name().unwrap(); + let out = run_sysand_in( + project_cwd, + ["init", "--version", "1.2.3", "--name", project_name], + None, + )?; + out.assert().success(); + + std::fs::write(project_cwd.join("test.sysml"), b"package P;\n")?; + let out = run_sysand_in( + project_cwd, + ["include", "--no-index-symbols", "test.sysml"], + None, + )?; + out.assert().success(); + + std::fs::write(project_cwd.join("README.md"), readme_content.as_bytes())?; + } + + let out = run_sysand_in(&cwd, ["build"], None)?; + out.assert().success(); + + for (project_name, expected_readme) in + [("project1", "# Project 1\n"), ("project2", "# Project 2\n")] + { + let kpar_path = cwd + .join("output") + .join(format!("{}-1.2.3.kpar", project_name)); + assert!( + kpar_path.is_file(), + "kpar file does not exist: {}", + kpar_path + ); + + assert_kpar_readme(&kpar_path, expected_readme); + } + + Ok(()) +} + +fn assert_kpar_readme(kpar_path: &camino::Utf8Path, expected: &str) { + let file = std::fs::File::open(kpar_path).unwrap(); + let mut archive = zip::ZipArchive::new(file).unwrap(); + let mut readme = archive.by_name("README.md").unwrap(); + let mut content = String::new(); + readme.read_to_string(&mut content).unwrap(); + assert_eq!(content, expected, "README mismatch in {kpar_path}"); +} + +fn assert_kpar_no_readme(kpar_path: &camino::Utf8Path) { + let file = std::fs::File::open(kpar_path).unwrap(); + let mut archive = zip::ZipArchive::new(file).unwrap(); + assert!( + archive.by_name("README.md").is_err(), + "KPAR should not contain README.md: {kpar_path}" + ); +} + fn test_compression_method(compression: Option<&str>) -> Result<(), Box> { let (_temp_dir, cwd, out) = run_sysand(["init", "--version", "1.2.3", "--name", "test_build"], None)?;