diff --git a/core/src/commands/lock.rs b/core/src/commands/lock.rs index ca3fada76..000f5235a 100644 --- a/core/src/commands/lock.rs +++ b/core/src/commands/lock.rs @@ -163,16 +163,6 @@ pub fn do_lock_projects< field: IncompleteField::Meta, }) })?; - // let canonical_digest = project - // .checksum_canonical_hex() - // .map_err(LockProjectError::InputProjectCanonicalizationError)? - // .ok_or_else(|| { - // LockProjectError::LockError(LockError::IncompleteProject { - // project_label: named_project_label, - // field: IncompleteField::CanonicalDigest, - // }) - // })?; - let sources = project .sources(ctx) .map_err(LockProjectError::InputProjectError)?; @@ -186,7 +176,6 @@ pub fn do_lock_projects< identifiers: identifiers .map(|ids| ids.into_iter().map(|id| id.into_string()).collect()) .unwrap_or_default(), - // checksum: canonical_digest, sources, usages: info .usage diff --git a/core/src/commands/lock_tests.rs b/core/src/commands/lock_tests.rs index 428b0e33b..6dec648e8 100644 --- a/core/src/commands/lock_tests.rs +++ b/core/src/commands/lock_tests.rs @@ -4,8 +4,10 @@ use std::collections::HashMap; use crate::{ - commands::lock::{LockError, do_lock_extend}, - lock::{Lock, Project}, + commands::lock::{LockError, do_lock_extend, do_lock_projects}, + lock::{Lock, Project, Source}, + model::{InterchangeProjectInfoRaw, InterchangeProjectMetadataRaw}, + project::memory::InMemoryProject, resolve::null::NullResolver, }; @@ -46,3 +48,42 @@ fn lock_export_conflict() { assert!(matches!(res, Err(LockError::NameCollision(_)))); } + +#[test] +fn lock_preserves_project_publisher() { + let mut project = InMemoryProject::from_info_meta( + InterchangeProjectInfoRaw { + name: "published_project".into(), + publisher: Some("Acme Labs".into()), + version: "1.2.3".into(), + description: None, + license: None, + maintainer: vec![], + website: None, + topic: vec![], + usage: vec![], + }, + InterchangeProjectMetadataRaw { + index: Default::default(), + created: "2026-01-01T00:00:00Z".into(), + metamodel: None, + includes_derived: None, + includes_implied: None, + checksum: None, + }, + ); + project.nominal_sources = vec![Source::Editable { + editable: ".".into(), + }]; + + let lock = do_lock_projects( + [(None, &project)], + NullResolver {}, + &HashMap::new(), + &Default::default(), + ) + .unwrap() + .lock; + + assert_eq!(lock.projects[0].publisher.as_deref(), Some("Acme Labs")); +} diff --git a/core/src/env/local_directory/mod.rs b/core/src/env/local_directory/mod.rs index c22734bb3..b3db9e364 100644 --- a/core/src/env/local_directory/mod.rs +++ b/core/src/env/local_directory/mod.rs @@ -104,19 +104,17 @@ impl LocalDirectoryEnvironment { // TODO: Integrate the updating of editable metadata into `WriteEnvironment` trait. // This will likely require updating it to support // multiple identifiers per project. - /// Precondition: sync has completed, i.e. projects from `lock` are installed - /// by `self.put_project()`. - /// Call is idempotent. - /// Does not update metadata file. - // TODO: what to do if lock does not contain projects that are present in env: - // - workspace: env also has to remove it, the project was deleted/renamed - // - editable: unclear; it could be (re)moved/renamed, but also it could be that - // workspace just no longer depends on it, so it's absent from lock. + /// Updates list of projects present in env metadata, but not installed in the + /// environment (for now this includes only `editable` projects). All previous + /// non-installed projects are removed first. + /// To install a project in the environment, use `put_project()`. + /// Call is idempotent. Does not update metadata file pub fn merge_lock(&mut self, lock: &Lock, ws: Option<&Workspace>) { + self.metadata.projects.retain(Self::is_installed); for project in &lock.projects { // Projects that are installed in the environment are ignored, so only // editable (and workspace, which are a subset of editable) projects have to be added - if let [Source::Editable { editable }, ..] = project.sources.as_slice() { + if let Some(Source::Editable { editable }) = project.sources.first() { let usages = project .usages .iter() @@ -126,34 +124,18 @@ impl LocalDirectoryEnvironment { let workspace_member = ws .map(|w| w.projects().iter().any(|p| p.path.as_str() == editable)) .unwrap_or_default(); - // This is called once per `sync`, so has to be idempotent - if let Some(existing) = self - .metadata - .find_project_version_any_mut(&project.identifiers, &project.version) - { - assert_eq!(existing.workspace, workspace_member); - assert!(existing.editable); - - for iri in &project.identifiers { - if !existing.identifiers.contains(iri) { - existing.identifiers.push(iri.to_owned()); - } - } - existing.path = editable.as_str().into(); - existing.usages = usages; - } else { - self.metadata.projects.push(EnvProject { - publisher: project.publisher.to_owned(), - name: project.name.to_owned(), - version: project.version.to_owned(), - path: editable.as_str().into(), - identifiers: project.identifiers.to_owned(), - usages, - editable: true, - workspace: workspace_member, - checksum: None, - }); - } + + self.metadata.projects.push(EnvProject { + publisher: project.publisher.to_owned(), + name: project.name.to_owned(), + version: project.version.to_owned(), + path: editable.as_str().into(), + identifiers: project.identifiers.to_owned(), + usages, + editable: true, + workspace: workspace_member, + checksum: None, + }); } } } diff --git a/core/src/lock.rs b/core/src/lock.rs index ff4010c82..582eb363b 100644 --- a/core/src/lock.rs +++ b/core/src/lock.rs @@ -395,6 +395,9 @@ pub fn hash_str(val: &str) -> StrHash { impl Project { pub fn to_toml(&self) -> Table { let mut table = Table::new(); + if let Some(publisher) = &self.publisher { + table.insert("publisher", value(publisher)); + } table.insert("name", value(&self.name)); table.insert("version", value(&self.version)); let exports = multiline_array(self.exports.iter().map(Value::from)); diff --git a/core/src/lock_tests.rs b/core/src/lock_tests.rs index c1dfd8df1..4c37bfc3c 100644 --- a/core/src/lock_tests.rs +++ b/core/src/lock_tests.rs @@ -132,7 +132,7 @@ fn many_projects_to_toml() { vec![ Project { name: "One".to_string(), - publisher: None, + publisher: Some("Pub 1".to_string()), version: "0.0.1".to_string(), exports: vec![], identifiers: vec![], @@ -150,7 +150,7 @@ fn many_projects_to_toml() { }, Project { name: "Three".to_string(), - publisher: None, + publisher: Some("Pub 3".to_string()), version: "0.0.3".to_string(), exports: vec![], identifiers: vec![], @@ -160,6 +160,7 @@ fn many_projects_to_toml() { ], r#" [[project]] +publisher = "Pub 1" name = "One" version = "0.0.1" @@ -168,6 +169,7 @@ name = "Two" version = "0.0.2" [[project]] +publisher = "Pub 3" name = "Three" version = "0.0.3" "#, diff --git a/core/src/project/local_kpar.rs b/core/src/project/local_kpar.rs index 3ea9a8d72..902d57dc0 100644 --- a/core/src/project/local_kpar.rs +++ b/core/src/project/local_kpar.rs @@ -393,6 +393,15 @@ impl LocalKParProjectRaw { &self.archive_path } + /// Returns project root in archive. If `None`, project is at the + /// root of the archive + pub fn project_root_in_archive(&self) -> Option<&Utf8UnixPath> { + // TODO: maybe it'd be worth enforcing that Some(p) => p is not empty? + // Would simplify a bunch of places which currently must check + // both + self.root.as_deref() + } + /// Build a KPAR archive from `from`. /// /// `extra_files` are added to the archive alongside the project's source @@ -461,6 +470,23 @@ impl LocalKParProjectRaw { Ok(wrapfs::metadata(&self.archive_path)?.len()) } + pub fn digest_sha256(&self) -> Result { + let mut file = self.open_archive_file()?; + let mut buf = [0; 1024]; + let mut hasher = Sha256::new(); + loop { + let count = file + .read(&mut buf) + .map_err(|e| FsIoError::ReadFile(self.archive_path.clone(), e))?; + if count > 0 { + hasher.update(&buf[..count]); + } else { + break; + } + } + Ok(lowercase_hex(hasher.finalize())) + } + fn open_archive_file(&self) -> Result { Ok(wrapfs::File::open(&self.archive_path)?) } @@ -502,23 +528,6 @@ impl LocalKParProjectRaw { ))), } } - - pub fn digest_sha256(&self) -> Result { - let mut file = self.open_archive_file()?; - let mut buf = [0; 1024]; - let mut hasher = Sha256::new(); - loop { - let count = file - .read(&mut buf) - .map_err(|e| FsIoError::ReadFile(self.archive_path.clone(), e))?; - if count > 0 { - hasher.update(&buf[..count]); - } else { - break; - } - } - Ok(lowercase_hex(hasher.finalize())) - } } // NOTE: Current implementation keeps re-opening the archive file. This appears to @@ -575,12 +584,6 @@ impl ProjectRead for LocalKParProjectRaw { .get_relative(&mut archive, path) .map_err(|(p, e)| ZipArchiveError::NamedFileMeta(p.into_string().into(), e))?; - // let idx = path_index(self.root.as_deref(), &mut archive, &path)?; - - // let mut zip_file = archive - // .by_index(idx) - // .map_err(|e| ZipArchiveError::NamedFileMeta(path.as_ref().as_str().into(), e))?; - std::io::copy(&mut zip_file, &mut tmp_file) .map_err(|e| FsIoError::WriteFile(tmp_file_path.clone(), e))?; } diff --git a/sysand/tests/cli_sync.rs b/sysand/tests/cli_sync.rs index 5d7346f5a..78d0aaadc 100644 --- a/sysand/tests/cli_sync.rs +++ b/sysand/tests/cli_sync.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 // SPDX-FileCopyrightText: © 2025 Sysand contributors +use std::fs; + use assert_cmd::prelude::*; use indexmap::IndexMap; use mockito::Matcher; @@ -22,7 +24,7 @@ fn sync_to_current() -> Result<(), Box> { None, )?; - std::fs::write(cwd.join("test.sysml"), b"package P;\n")?; + fs::write(cwd.join("test.sysml"), b"package P;\n")?; out.assert().success(); @@ -39,7 +41,7 @@ fn sync_to_current() -> Result<(), Box> { let env_path = cwd.join(DEFAULT_ENV_NAME); - let env_metadata = std::fs::read_to_string(env_path.join(METADATA_PATH))?; + let env_metadata = fs::read_to_string(env_path.join(METADATA_PATH))?; assert_eq!( env_metadata, @@ -58,7 +60,7 @@ editable = true ) ); - let entries: Result, _> = std::fs::read_dir(env_path)?.collect(); + let entries: Result, _> = fs::read_dir(env_path)?.collect(); let mut entry_names: Vec<_> = entries? .iter() @@ -72,16 +74,82 @@ editable = true Ok(()) } +#[test] +fn repeated_sync_keeps_lockfile_and_env_toml_stable() -> Result<(), Box> { + let (_temp_dir, cwd, out) = run_sysand( + ["init", "--version", "1.2.3", "--name", "lock_sync_stable"], + None, + )?; + out.assert().success().stdout(predicate::str::is_empty()); + + let (_dep_temp_dir, dep_cwd, out) = run_sysand( + [ + "init", + "--version", + "2.0.0", + "--name", + "lock_sync_stable_dep", + ], + None, + )?; + out.assert().success().stdout(predicate::str::is_empty()); + + let config_path = cwd.join("sysand.toml"); + let cfg = Some(config_path.as_str()); + + run_sysand_in( + &cwd, + [ + "add", + "--no-lock", + "urn:kpar:lock-sync-stable-dep", + "--as-editable", + dep_cwd.as_str(), + ], + cfg, + )? + .assert() + .success(); + + run_sysand_in(&cwd, ["lock"], cfg)? + .assert() + .success() + .stdout(predicate::str::is_empty()); + + let lock_path = cwd.join(DEFAULT_LOCKFILE_NAME); + let recorded_lockfile = fs::read_to_string(&lock_path)?; + + run_sysand_in(&cwd, ["sync"], cfg)?.assert().success(); + + let env_meta_path = cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH); + let recorded_env_toml = fs::read_to_string(&env_meta_path)?; + + run_sysand_in(&cwd, ["sync"], cfg)?.assert().success(); + + assert_eq!( + fs::read_to_string(&lock_path)?, + recorded_lockfile, + "sysand-lock.toml changed after repeated syncs" + ); + assert_eq!( + fs::read_to_string(&env_meta_path)?, + recorded_env_toml, + ".sysand/env.toml changed after repeated syncs" + ); + + Ok(()) +} + #[test] fn sync_to_local() -> Result<(), Box> { let (_temp_dir, cwd) = new_temp_cwd()?; // Create local project that can be referred to with src_path let lib_dir = cwd.join("lib"); - std::fs::create_dir(&lib_dir)?; + fs::create_dir(&lib_dir)?; let proj_dir = lib_dir.join("sync_to_local"); - std::fs::create_dir(&proj_dir)?; - std::fs::write( + fs::create_dir(&proj_dir)?; + fs::write( proj_dir.join(".project.json"), r#"{ "name": "sync_to_local", @@ -89,7 +157,7 @@ fn sync_to_local() -> Result<(), Box> { } "#, )?; - std::fs::write( + fs::write( proj_dir.join(".meta.json"), r#"{ "index": {}, @@ -98,7 +166,7 @@ fn sync_to_local() -> Result<(), Box> { "#, )?; - std::fs::write( + fs::write( cwd.join(DEFAULT_LOCKFILE_NAME), r#"lock_version = "0.5" @@ -120,7 +188,7 @@ sources = [ .stderr(predicate::str::contains("Syncing")) .stderr(predicate::str::contains("Installing")); - let env_metadata = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH))?; + let env_metadata = fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH))?; assert_eq!( env_metadata, @@ -180,7 +248,7 @@ fn sync_to_remote() -> Result<(), Box> { .match_request(|r| r.has_header(header::USER_AGENT)) .create(); - std::fs::write( + fs::write( cwd.join(DEFAULT_LOCKFILE_NAME), format!( r#"lock_version = "0.5" @@ -208,7 +276,7 @@ sources = [ info_mock.assert(); meta_mock.assert(); - let env_metadata = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH))?; + let env_metadata = fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH))?; assert_eq!( env_metadata, @@ -295,7 +363,7 @@ fn sync_to_remote_auth() -> Result<(), Box> { .expect(2) // TODO: Reduce this to 1 .create(); - std::fs::write( + fs::write( cwd.join(DEFAULT_LOCKFILE_NAME), format!( r#"lock_version = "0.5" @@ -400,7 +468,7 @@ fn sync_to_remote_incorrect_auth() -> Result<(), Box> { .expect(0) .create(); - std::fs::write( + fs::write( cwd.join(DEFAULT_LOCKFILE_NAME), format!( r#"lock_version = "0.5" @@ -448,7 +516,7 @@ fn sync_env_toml_with_editable_and_non_editable() -> Result<(), Box Result<(), Box Result<(), Box