Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions core/src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,10 +321,29 @@ fn do_build_kpar_inner<P: AsRef<Utf8Path>, 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(),
)?)
}

Expand Down
8 changes: 8 additions & 0 deletions core/src/project/local_kpar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ impl LocalKParProject {
from: &Pr,
path: P,
compression: zip::CompressionMethod,
readme: Option<&str>,
) -> Result<Self, IntoKparError<Pr::Error>> {
let file = wrapfs::File::create(&path)?;
let mut zip = zip::ZipWriter::new(file);
Expand Down Expand Up @@ -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))?;

Expand Down
4 changes: 4 additions & 0 deletions core/src/project/local_src.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down
6 changes: 6 additions & 0 deletions core/src/project/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: © 2025 Sysand contributors <opensource@sensmetry.com>
// SPDX-License-Identifier: MIT OR Apache-2.0

use camino::Utf8Path;
use futures::io::{AsyncBufReadExt as _, AsyncRead};
use indexmap::IndexMap;
use sha2::{Digest, Sha256};
Expand Down Expand Up @@ -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<Option<InterchangeProjectInfoRaw>, Self::Error> {
Ok(self.get_project()?.0)
}
Expand Down
3 changes: 3 additions & 0 deletions docs/src/commands/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
129 changes: 129 additions & 0 deletions sysand/tests/cli_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,135 @@ fn test_compression_methods() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}

/// Build a project with a README.md at the project root
#[test]
fn project_build_with_readme() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let (_temp_dir, cwd, out) =
run_sysand(["init", "--version", "1.2.3", "--name", "test_build"], None)?;
Expand Down