From a0d693049b9acd7275d5c53fbc7b950ed19f0922 Mon Sep 17 00:00:00 2001 From: "victor.linroth.sensmetry" Date: Mon, 5 Jan 2026 14:30:41 +0100 Subject: [PATCH 01/14] Adds a resolved manifest to `sysand_env` keeping track of what is part of the current project. Signed-off-by: victor.linroth.sensmetry --- core/src/commands/sources.rs | 7 +- core/src/env/local_directory.rs | 151 ++++++++++++++++++++++++++++++-- core/src/lock.rs | 27 ++++-- sysand/src/commands/add.rs | 1 + sysand/src/commands/clone.rs | 1 + sysand/src/commands/env.rs | 2 + sysand/src/commands/sync.rs | 17 +++- sysand/src/lib.rs | 1 + sysand/tests/cli_env.rs | 15 ++-- sysand/tests/cli_sync.rs | 81 ++++++++++++++++- 10 files changed, 281 insertions(+), 22 deletions(-) diff --git a/core/src/commands/sources.rs b/core/src/commands/sources.rs index ac916593..361736ae 100644 --- a/core/src/commands/sources.rs +++ b/core/src/commands/sources.rs @@ -136,5 +136,10 @@ pub fn enumerate_projects_lock( Vec<::InterchangeProjectRead>, ResolutionError<::ReadError>, > { - lock.resolve_projects(env) + let projects = lock + .resolve_projects(env)? + .into_iter() + .filter_map(|(_, project_read)| project_read) + .collect(); + Ok(projects) } diff --git a/core/src/env/local_directory.rs b/core/src/env/local_directory.rs index e20e6d85..a4e16058 100644 --- a/core/src/env/local_directory.rs +++ b/core/src/env/local_directory.rs @@ -1,16 +1,24 @@ // SPDX-FileCopyrightText: © 2025 Sysand contributors // SPDX-License-Identifier: MIT OR Apache-2.0 -use camino::{Utf8Path, Utf8PathBuf}; -use camino_tempfile::NamedUtf8TempFile; -use sha2::Sha256; use std::{ + collections::HashMap, + fmt::Display, fs, io::{self, BufRead, BufReader, Read, Write}, + num::TryFromIntError, }; +use camino::{Utf8Path, Utf8PathBuf}; +use camino_tempfile::NamedUtf8TempFile; +use sha2::Sha256; +use thiserror::Error; +use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, Value, value}; + use crate::{ + commands::sources::{LocalSourcesError, do_sources_local_src_project_no_deps}, env::{PutProjectError, ReadEnvironment, WriteEnvironment, segment_uri_generic}, + lock::{Lock, ResolutionError, Source, multiline_array}, project::{ local_src::{LocalSrcError, LocalSrcProject, PathError}, utils::{ @@ -20,8 +28,6 @@ use crate::{ }, }; -use thiserror::Error; - #[derive(Clone, Debug)] pub struct LocalDirectoryEnvironment { pub environment_path: Utf8PathBuf, @@ -29,6 +35,8 @@ pub struct LocalDirectoryEnvironment { pub const DEFAULT_ENV_NAME: &str = "sysand_env"; +pub const DEFAULT_MANIFEST_NAME: &str = "current.toml"; + pub const ENTRIES_PATH: &str = "entries.txt"; pub const VERSIONS_PATH: &str = "versions.txt"; @@ -645,3 +653,136 @@ impl WriteEnvironment for LocalDirectoryEnvironment { Ok(()) } } + +#[derive(Debug, Error)] +pub enum ResolvedManifestError { + #[error(transparent)] + ResolutionError(#[from] ResolutionError), + #[error("too many dependencies, unable to convert to i64: {0}")] + TooManyDependencies(TryFromIntError), + #[error(transparent)] + LocalSources(#[from] LocalSourcesError), + #[error(transparent)] + Canonicalization(#[from] Box), +} + +impl Lock { + pub fn to_resolved_manifest>( + &self, + env: &LocalDirectoryEnvironment, + root_path: P, + ) -> Result { + let resolved_projects = self.resolve_projects(env)?; + + let indices = resolved_projects + .iter() + .map(|(p, _)| p) + .enumerate() + .flat_map(|(num, p)| p.identifiers.iter().map(move |iri| (iri.clone(), num))) + .map(|(iri, num)| i64::try_from(num).map(|num| (iri, num))) + .collect::, _>>() + .map_err(ResolvedManifestError::TooManyDependencies)?; + let indices = HashMap::::from_iter(indices); + + let mut projects = vec![]; + for (project, storage) in resolved_projects { + let usages = project + .usages + .iter() + .filter_map(|usage| indices.get(&usage.resource)) + .copied() + .collect(); + + if let Some(storage) = storage { + let directory = storage.root_path().to_owned(); + projects.push(ResolvedProject { + name: project.name, + location: ResolvedLocation::Directory(directory), + usages, + }); + } else if let [Source::Editable { editable }, ..] = project.sources.as_slice() { + let project_path = root_path.as_ref().join(editable.as_str()); + let editable_project = LocalSrcProject { + nominal_path: None, + project_path: wrapfs::canonicalize(project_path)?, + }; + let files = do_sources_local_src_project_no_deps(&editable_project, true)? + .into_iter() + .collect(); + projects.push(ResolvedProject { + name: project.name, + location: ResolvedLocation::Files(files), + usages, + }); + } + } + + Ok(ResolvedManifest { projects }) + } +} + +#[derive(Debug)] +pub struct ResolvedManifest { + pub projects: Vec, +} + +impl Display for ResolvedManifest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_toml()) + } +} + +impl ResolvedManifest { + pub fn to_toml(&self) -> DocumentMut { + let mut doc = DocumentMut::new(); + let mut projects = ArrayOfTables::new(); + for project in &self.projects { + projects.push(project.to_toml()); + } + doc.insert("project", Item::ArrayOfTables(projects)); + + doc + } +} + +#[derive(Debug)] +pub enum ResolvedLocation { + Directory(Utf8PathBuf), + Files(Vec), +} + +#[derive(Debug)] +pub struct ResolvedProject { + pub name: Option, + pub location: ResolvedLocation, + pub usages: Vec, +} + +impl ResolvedProject { + pub fn to_toml(&self) -> Table { + let mut table = Table::new(); + if let Some(name) = &self.name { + table.insert("name", value(name)); + } + match &self.location { + ResolvedLocation::Directory(dir) => { + table.insert("directory", value(dir.as_str())); + } + ResolvedLocation::Files(files) => { + if !files.is_empty() { + table.insert( + "files", + value(multiline_array( + files.iter().map(|f| Value::from(f.as_str())), + )), + ); + } + } + } + if !self.usages.is_empty() { + let usages = Array::from_iter(self.usages.iter().copied().map(Value::from)); + table.insert("usages", value(usages)); + } + table + } +} diff --git a/core/src/lock.rs b/core/src/lock.rs index 3bd95a49..b7f06a6d 100644 --- a/core/src/lock.rs +++ b/core/src/lock.rs @@ -182,18 +182,33 @@ pub enum ValidationError { }, } +pub type ProjectResolution = ( + Project, + Option<::InterchangeProjectRead>, +); + impl Lock { pub fn resolve_projects( &self, env: &Env, - ) -> Result< - Vec<::InterchangeProjectRead>, - ResolutionError, - > { + ) -> Result>, ResolutionError> { let mut missing = vec![]; let mut found = vec![]; for project in &self.projects { + // Projects without sources (default for standard libraries) and + // projects with editable sources won't be installed in environment. + match project.sources.as_slice() { + [] => { + continue; + } + [Source::Editable { editable: _ }, ..] => { + found.push((project.clone(), None)); + continue; + } + _ => {} + } + let checksum = &project.checksum; let mut resolved_project = None; @@ -212,8 +227,8 @@ impl Lock { } } - if let Some(success) = resolved_project { - found.push(success); + if resolved_project.is_some() { + found.push((project.clone(), resolved_project)); } else { missing.push(project.clone()); } diff --git a/sysand/src/commands/add.rs b/sysand/src/commands/add.rs index da4d3eea..f0f29de0 100644 --- a/sysand/src/commands/add.rs +++ b/sysand/src/commands/add.rs @@ -275,6 +275,7 @@ fn resolve_deps, Policy: HTTPAuthentication>( command_sync( &lock, project_root, + true, &mut env, client, &provided_iris, diff --git a/sysand/src/commands/clone.rs b/sysand/src/commands/clone.rs index 9db2229a..566d5902 100644 --- a/sysand/src/commands/clone.rs +++ b/sysand/src/commands/clone.rs @@ -142,6 +142,7 @@ pub fn command_clone( command_sync( &lock, &project.inner().project_path, + true, &mut env, client, &provided_iris, diff --git a/sysand/src/commands/env.rs b/sysand/src/commands/env.rs index 4860fa8d..c466ec2c 100644 --- a/sysand/src/commands/env.rs +++ b/sysand/src/commands/env.rs @@ -159,6 +159,7 @@ pub fn command_env_install( command_sync( &lock, project_root, + false, &mut env, client, &provided_iris, @@ -295,6 +296,7 @@ pub fn command_env_install_path( command_sync( &lock, project_root, + false, &mut env, client, &provided_iris, diff --git a/sysand/src/commands/sync.rs b/sysand/src/commands/sync.rs index a89bb19c..543badd2 100644 --- a/sysand/src/commands/sync.rs +++ b/sysand/src/commands/sync.rs @@ -9,7 +9,7 @@ use url::ParseError; use sysand_core::{ auth::HTTPAuthentication, - env::local_directory::LocalDirectoryEnvironment, + env::local_directory::{DEFAULT_ENV_NAME, DEFAULT_MANIFEST_NAME, LocalDirectoryEnvironment}, lock::Lock, project::{ AsSyncProjectTokio, ProjectReadAsync, @@ -19,12 +19,15 @@ use sysand_core::{ memory::InMemoryProject, reqwest_kpar_download::ReqwestKparDownloadedProject, reqwest_src::ReqwestSrcProjectAsync, + utils::wrapfs, }, }; +#[allow(clippy::too_many_arguments)] pub fn command_sync, Policy: HTTPAuthentication>( lock: &Lock, project_root: P, + update_manifest: bool, env: &mut LocalDirectoryEnvironment, client: reqwest_middleware::ClientWithMiddleware, provided_iris: &HashMap>, @@ -65,5 +68,17 @@ pub fn command_sync, Policy: HTTPAuthentication>( }), provided_iris, )?; + + if update_manifest { + let manifest = lock.to_resolved_manifest(env, &project_root)?; + wrapfs::write( + project_root + .as_ref() + .join(DEFAULT_ENV_NAME) + .join(DEFAULT_MANIFEST_NAME), + manifest.to_string(), + )?; + } + Ok(()) } diff --git a/sysand/src/lib.rs b/sysand/src/lib.rs index 972ee4d5..3e9577e3 100644 --- a/sysand/src/lib.rs +++ b/sysand/src/lib.rs @@ -419,6 +419,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { command_sync( &lock, project_root, + true, &mut local_environment, client, &provided_iris, diff --git a/sysand/tests/cli_env.rs b/sysand/tests/cli_env.rs index c8194e58..3e288638 100644 --- a/sysand/tests/cli_env.rs +++ b/sysand/tests/cli_env.rs @@ -7,7 +7,7 @@ use assert_cmd::prelude::*; use camino::Utf8Path; use mockito::Server; use predicates::prelude::*; -use sysand_core::env::local_directory::DEFAULT_ENV_NAME; +use sysand_core::env::local_directory::{DEFAULT_ENV_NAME, ENTRIES_PATH, VERSIONS_PATH}; // pub due to https://github.com/rust-lang/rust/issues/46379 mod common; @@ -31,13 +31,12 @@ fn env_init_empty_env() -> Result<(), Box> { if path.is_dir() { assert_eq!(path.strip_prefix(&cwd)?, env_path); } else { - // if path.is_file() - assert_eq!(path.strip_prefix(&cwd)?, env_path.join("entries.txt")); + assert_eq!(path.strip_prefix(&cwd)?, env_path.join(ENTRIES_PATH)); } } assert_eq!( - std::fs::File::open(cwd.join("sysand_env/entries.txt"))? + std::fs::File::open(cwd.join(DEFAULT_ENV_NAME).join(ENTRIES_PATH))? .metadata()? .len(), 0 @@ -75,7 +74,7 @@ fn env_install_from_local_dir() -> Result<(), Box> { .stderr(predicate::str::contains("`urn:kpar:test` 0.0.1")); assert_eq!( - std::fs::read_to_string(cwd.join(env_path).join("entries.txt"))?, + std::fs::read_to_string(cwd.join(env_path).join(ENTRIES_PATH))?, "urn:kpar:test\n" ); @@ -84,7 +83,7 @@ fn env_install_from_local_dir() -> Result<(), Box> { assert!(cwd.join(env_path).join(test_hash).is_dir()); assert_eq!( - std::fs::read_to_string(cwd.join(env_path).join(test_hash).join("versions.txt"))?, + std::fs::read_to_string(cwd.join(env_path).join(test_hash).join(VERSIONS_PATH))?, "0.0.1\n" ); @@ -127,7 +126,7 @@ fn env_install_from_local_dir() -> Result<(), Box> { assert_eq!(entries.len(), 1); - assert_eq!(entries[0].file_name(), "entries.txt"); + assert_eq!(entries[0].file_name(), ENTRIES_PATH); assert_eq!(std::fs::read_to_string(entries[0].path())?, ""); @@ -190,7 +189,7 @@ fn env_install_from_http_kpar() -> Result<(), Box> { out.assert().success(); assert_eq!( - std::fs::read_to_string(cwd.join(env_path).join("entries.txt"))?, + std::fs::read_to_string(cwd.join(env_path).join(ENTRIES_PATH))?, format!("{}\n", &project_url) ); diff --git a/sysand/tests/cli_sync.rs b/sysand/tests/cli_sync.rs index 6396b094..8dee2904 100644 --- a/sysand/tests/cli_sync.rs +++ b/sysand/tests/cli_sync.rs @@ -6,12 +6,65 @@ use indexmap::IndexMap; use mockito::Matcher; use predicates::prelude::*; use reqwest::header; -use sysand_core::commands::lock::DEFAULT_LOCKFILE_NAME; +use sysand_core::{ + commands::lock::DEFAULT_LOCKFILE_NAME, + env::local_directory::{DEFAULT_ENV_NAME, DEFAULT_MANIFEST_NAME, ENTRIES_PATH}, +}; // pub due to https://github.com/rust-lang/rust/issues/46379 mod common; pub use common::*; +#[test] +fn sync_to_current() -> Result<(), Box> { + let (_temp_dir, cwd, out) = run_sysand( + ["init", "--version", "1.2.3", "--name", "sync_to_current"], + None, + )?; + + std::fs::write(cwd.join("test.sysml"), b"package P;\n")?; + + out.assert().success(); + + let out = run_sysand_in(&cwd, ["include", "test.sysml"], None)?; + + out.assert().success(); + + let out = run_sysand_in(&cwd, ["sync"], None)?; + + out.assert() + .success() + .stderr(predicate::str::contains("Creating")) + .stderr(predicate::str::contains("Syncing")); + + let env_path = cwd.join(DEFAULT_ENV_NAME); + + let manifest = std::fs::read_to_string(env_path.join(DEFAULT_MANIFEST_NAME))?; + + assert_eq!( + manifest, + format!( + r#"[[project]] +name = "sync_to_current" +files = [ + "{}/test.sysml", +] +"#, + cwd + ) + ); + + let entries = std::fs::read_dir(env_path)?.collect::, _>>()?; + + assert_eq!(entries.len(), 2); + + assert_eq!(entries[0].file_name(), DEFAULT_MANIFEST_NAME); + + assert_eq!(entries[1].file_name(), ENTRIES_PATH); + + Ok(()) +} + #[test] fn sync_to_local() -> Result<(), Box> { let (_temp_dir, cwd) = new_temp_cwd()?; @@ -62,6 +115,19 @@ sources = [ .stderr(predicate::str::contains("Syncing")) .stderr(predicate::str::contains("Installing")); + let manifest = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(DEFAULT_MANIFEST_NAME))?; + + assert_eq!( + manifest, + format!( + r#"[[project]] +name = "sync_to_local" +directory = "{}/{DEFAULT_ENV_NAME}/5ddc0a2e8aaa88ac2bfc71aa0a8d08e020bceac4a90a4b72d8fb7f97ec5bfcc5/1.2.3.kpar" +"#, + cwd + ) + ); + let out = run_sysand_in(&cwd, ["env", "list"], None)?; out.assert() @@ -130,6 +196,19 @@ sources = [ info_mock.assert(); meta_mock.assert(); + let manifest = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(DEFAULT_MANIFEST_NAME))?; + + assert_eq!( + manifest, + format!( + r#"[[project]] +name = "sync_to_remote" +directory = "{}/{DEFAULT_ENV_NAME}/2b95cb7c6d6c08695b0e7c4b7e9d836c21de37fb9c72b0cfa26f53fd84a1b459/1.2.3.kpar" +"#, + cwd + ) + ); + let out = run_sysand_in(&cwd, ["env", "list"], None)?; out.assert() From c7c116c1c6c86826d839807604cf315a078e392c Mon Sep 17 00:00:00 2001 From: "victor.linroth.sensmetry" Date: Tue, 10 Feb 2026 21:04:48 +0100 Subject: [PATCH 02/14] Add optional publisher to manifest. Signed-off-by: victor.linroth.sensmetry --- Cargo.lock | 11 +++++++++++ core/Cargo.toml | 1 + core/src/env/local_directory.rs | 15 +++++++++++++-- core/src/lock.rs | 8 ++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 81ce69e9..ca63faaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2409,6 +2409,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "packageurl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35da99768af1ae8830ccf30d295db0e09c24bcfda5a67515191dd4b773f6d82a" +dependencies = [ + "percent-encoding", + "thiserror 2.0.18", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -3358,6 +3368,7 @@ dependencies = [ "log", "logos", "mockito", + "packageurl", "port_check", "predicates", "pubgrub", diff --git a/core/Cargo.toml b/core/Cargo.toml index 51e80f34..37988a1c 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -68,6 +68,7 @@ bytes = { version = "1.11.1", default-features = false } toml_edit = { version = "0.25.4", features = ["serde"] } globset = { version = "0.4.18", default-features = false } reqwest = { version = "0.13.2", optional = true, features = ["rustls", "stream"] } +packageurl = "0.6.0" [dev-dependencies] assert_cmd = "2.1.2" diff --git a/core/src/env/local_directory.rs b/core/src/env/local_directory.rs index a4e16058..92719094 100644 --- a/core/src/env/local_directory.rs +++ b/core/src/env/local_directory.rs @@ -692,11 +692,17 @@ impl Lock { .filter_map(|usage| indices.get(&usage.resource)) .copied() .collect(); + let purl = project.get_package_url(); + let publisher = purl + .as_ref() + .and_then(|p| p.namespace().map(|ns| ns.to_owned())); + let name = purl.as_ref().map(|p| p.name().to_owned()).or(project.name); if let Some(storage) = storage { let directory = storage.root_path().to_owned(); projects.push(ResolvedProject { - name: project.name, + publisher, + name, location: ResolvedLocation::Directory(directory), usages, }); @@ -710,7 +716,8 @@ impl Lock { .into_iter() .collect(); projects.push(ResolvedProject { - name: project.name, + publisher, + name, location: ResolvedLocation::Files(files), usages, }); @@ -753,6 +760,7 @@ pub enum ResolvedLocation { #[derive(Debug)] pub struct ResolvedProject { + pub publisher: Option, pub name: Option, pub location: ResolvedLocation, pub usages: Vec, @@ -761,6 +769,9 @@ pub struct ResolvedProject { impl ResolvedProject { pub fn to_toml(&self) -> Table { let mut table = Table::new(); + if let Some(publisher) = &self.publisher { + table.insert("publisher", value(publisher)); + } if let Some(name) = &self.name { table.insert("name", value(name)); } diff --git a/core/src/lock.rs b/core/src/lock.rs index b7f06a6d..f96b6a52 100644 --- a/core/src/lock.rs +++ b/core/src/lock.rs @@ -10,6 +10,7 @@ use std::{ }; use fluent_uri::Iri; +use packageurl::PackageUrl; use semver::Version; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -477,6 +478,13 @@ impl Project { self.hash(&mut hasher); ProjectHash(hasher.finish()) } + + // Simple stopgap solution for now + pub fn get_package_url<'a>(&self) -> Option> { + self.identifiers + .first() + .and_then(|id| PackageUrl::from_str(id.as_str()).ok()) + } } const SOURCE_ENTRIES: &[&str] = &[ From 625ca12c3b0bb3e0951f22ae1c99d24316d834cd Mon Sep 17 00:00:00 2001 From: "victor.linroth.sensmetry" Date: Wed, 11 Feb 2026 09:29:35 +0100 Subject: [PATCH 03/14] Split up `local_directory`. Signed-off-by: victor.linroth.sensmetry --- core/src/env/local_directory.rs | 799 ----------------------- core/src/env/local_directory/manifest.rs | 162 +++++ core/src/env/local_directory/mod.rs | 361 ++++++++++ core/src/env/local_directory/utils.rs | 309 +++++++++ 4 files changed, 832 insertions(+), 799 deletions(-) delete mode 100644 core/src/env/local_directory.rs create mode 100644 core/src/env/local_directory/manifest.rs create mode 100644 core/src/env/local_directory/mod.rs create mode 100644 core/src/env/local_directory/utils.rs diff --git a/core/src/env/local_directory.rs b/core/src/env/local_directory.rs deleted file mode 100644 index 92719094..00000000 --- a/core/src/env/local_directory.rs +++ /dev/null @@ -1,799 +0,0 @@ -// SPDX-FileCopyrightText: © 2025 Sysand contributors -// SPDX-License-Identifier: MIT OR Apache-2.0 - -use std::{ - collections::HashMap, - fmt::Display, - fs, - io::{self, BufRead, BufReader, Read, Write}, - num::TryFromIntError, -}; - -use camino::{Utf8Path, Utf8PathBuf}; -use camino_tempfile::NamedUtf8TempFile; -use sha2::Sha256; -use thiserror::Error; -use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, Value, value}; - -use crate::{ - commands::sources::{LocalSourcesError, do_sources_local_src_project_no_deps}, - env::{PutProjectError, ReadEnvironment, WriteEnvironment, segment_uri_generic}, - lock::{Lock, ResolutionError, Source, multiline_array}, - project::{ - local_src::{LocalSrcError, LocalSrcProject, PathError}, - utils::{ - FsIoError, ProjectDeserializationError, ProjectSerializationError, RelativizePathError, - ToPathBuf, wrapfs, - }, - }, -}; - -#[derive(Clone, Debug)] -pub struct LocalDirectoryEnvironment { - pub environment_path: Utf8PathBuf, -} - -pub const DEFAULT_ENV_NAME: &str = "sysand_env"; - -pub const DEFAULT_MANIFEST_NAME: &str = "current.toml"; - -pub const ENTRIES_PATH: &str = "entries.txt"; -pub const VERSIONS_PATH: &str = "versions.txt"; - -/// Get a relative path corresponding to the given `uri` -pub fn path_encode_uri>(uri: S) -> Utf8PathBuf { - let mut result = Utf8PathBuf::new(); - for segment in segment_uri_generic::(uri) { - result.push(segment); - } - - result -} - -pub fn remove_dir_if_empty>(path: P) -> Result<(), FsIoError> { - match fs::remove_dir(path.as_ref()) { - Err(err) if err.kind() == io::ErrorKind::DirectoryNotEmpty => Ok(()), - r => r.map_err(|e| FsIoError::RmDir(path.to_path_buf(), e)), - } -} - -pub fn remove_empty_dirs>(path: P) -> Result<(), FsIoError> { - let mut dirs: Vec<_> = walkdir::WalkDir::new(path.as_ref()) - .into_iter() - .filter_map(|e| e.ok()) - .filter_map(|e| { - e.file_type() - .is_dir() - .then(|| Utf8PathBuf::from_path_buf(e.into_path()).ok()) - .flatten() - }) - .collect(); - - dirs.sort_by(|a, b| b.cmp(a)); - - for dir in dirs { - remove_dir_if_empty(&dir)?; - } - - Ok(()) -} - -#[derive(Error, Debug)] -pub enum TryMoveError { - #[error("recovered from failure: {0}")] - RecoveredIO(Box), - #[error( - "failed and may have left the directory in inconsistent state:\n{err}\nwhich was caused by:\n{cause}" - )] - CatastrophicIO { - err: Box, - cause: Box, - }, -} - -fn try_remove_files, I: Iterator>( - paths: I, -) -> Result<(), TryMoveError> { - let tempdir = camino_tempfile::tempdir() - .map_err(|e| TryMoveError::RecoveredIO(FsIoError::CreateTempFile(e).into()))?; - let mut moved: Vec = vec![]; - - for (i, path) in paths.enumerate() { - match move_fs_item(&path, tempdir.path().join(i.to_string())) { - Ok(_) => { - moved.push(path.to_path_buf()); - } - Err(cause) => { - // NOTE: This dance is to bypass the fact that std::io::error is not Clone-eable... - let mut catastrophic_error = None; - for (j, recover) in moved.iter().enumerate() { - if let Err(err) = move_fs_item(tempdir.path().join(j.to_string()), recover) { - catastrophic_error = Some(err); - break; - } - } - - if let Some(err) = catastrophic_error { - return Err(TryMoveError::CatastrophicIO { err, cause }); - } else { - return Err(TryMoveError::RecoveredIO(cause)); - } - } - } - } - - Ok(()) -} - -// Recursively copy a directory from `src` to `dst`. -// Assumes that all parents of `dst` exist. -fn copy_dir_recursive, Q: AsRef>( - src: P, - dst: Q, -) -> Result<(), Box> { - wrapfs::create_dir(&dst)?; - - for entry_result in wrapfs::read_dir(&src)? { - let entry = entry_result.map_err(|e| FsIoError::ReadDir(src.to_path_buf(), e))?; - let file_type = entry - .file_type() - .map_err(|e| FsIoError::ReadDir(src.to_path_buf(), e))?; - let src_path = entry.path(); - let dst_path = dst.as_ref().join(entry.file_name()); - - if file_type.is_dir() { - copy_dir_recursive(src_path, dst_path)?; - } else { - wrapfs::copy(src_path, dst_path)?; - } - } - - Ok(()) -} - -// Rename/move a file or directory from `src` to `dst`. -fn move_fs_item, Q: AsRef>( - src: P, - dst: Q, -) -> Result<(), Box> { - match fs::rename(src.as_ref(), dst.as_ref()) { - Ok(_) => Ok(()), - Err(e) if e.kind() == io::ErrorKind::CrossesDevices => { - let metadata = wrapfs::metadata(&src)?; - if metadata.is_dir() { - copy_dir_recursive(&src, &dst)?; - wrapfs::remove_dir_all(&src)?; - } else { - wrapfs::copy(&src, &dst)?; - wrapfs::remove_file(&src)?; - } - Ok(()) - } - Err(e) => Err(FsIoError::Move(src.to_path_buf(), dst.to_path_buf(), e))?, - } -} - -fn try_move_files(paths: &Vec<(&Utf8Path, &Utf8Path)>) -> Result<(), TryMoveError> { - let tempdir = camino_tempfile::tempdir() - .map_err(|e| TryMoveError::RecoveredIO(FsIoError::CreateTempFile(e).into()))?; - - let mut last_err = None; - - // move source files out of the way - for (i, (path, _)) in paths.iter().enumerate() { - let src_path = tempdir.path().join(format!("src_{}", i)); - if let Err(e) = move_fs_item(path, src_path) { - last_err = Some(e); - break; - } - } - - // Recover moved files in case of failure - if let Some(cause) = last_err { - for (i, (path, _)) in paths.iter().enumerate() { - let src_path = tempdir.path().join(format!("src_{}", i)); - - if src_path.exists() - && let Err(err) = move_fs_item(src_path, path) - { - return Err(TryMoveError::CatastrophicIO { err, cause }); - } - } - - return Err(TryMoveError::RecoveredIO(cause)); - } - - let mut last_err = None; - - // Move target files out of the way - for (i, (_, path)) in paths.iter().enumerate() { - if path.exists() { - let trg_path = tempdir.path().join(format!("trg_{}", i)); - if let Err(e) = move_fs_item(path, trg_path) { - last_err = Some(e); - break; - } - } - } - - // Recover moved files in case of failure - if let Some(cause) = last_err { - for (i, (_, path)) in paths.iter().enumerate() { - let trg_path = tempdir.path().join(format!("trg_{}", i)); - - if trg_path.exists() - && let Err(err) = move_fs_item(trg_path, path) - { - return Err(TryMoveError::CatastrophicIO { err, cause }); - } - } - - for (i, (path, _)) in paths.iter().enumerate() { - let src_path = tempdir.path().join(format!("src_{}", i)); - - if src_path.exists() - && let Err(err) = move_fs_item(src_path, path) - { - return Err(TryMoveError::CatastrophicIO { err, cause }); - } - } - - return Err(TryMoveError::RecoveredIO(cause)); - } - - let mut last_err = None; - - // Try moving files to destination - for (i, (_, target)) in paths.iter().enumerate() { - let src_path = tempdir.path().join(format!("src_{}", i)); - - if let Err(e) = move_fs_item(src_path, target) { - last_err = Some(e); - break; - } - } - - // Recover moved files in case of failure - if let Some(cause) = last_err { - for (i, (_, path)) in paths.iter().enumerate() { - let src_path = tempdir.path().join(format!("src_{}", i)); - - if path.exists() - && let Err(err) = move_fs_item(path, src_path) - { - return Err(TryMoveError::CatastrophicIO { err, cause }); - } - } - - for (i, (_, path)) in paths.iter().enumerate() { - let trg_path = tempdir.path().join(format!("trg_{}", i)); - - if trg_path.exists() - && let Err(err) = move_fs_item(trg_path, path) - { - return Err(TryMoveError::CatastrophicIO { err, cause }); - } - } - - for (i, (path, _)) in paths.iter().enumerate() { - let src_path = tempdir.path().join(format!("src_{}", i)); - - if src_path.exists() - && let Err(err) = move_fs_item(src_path, path) - { - return Err(TryMoveError::CatastrophicIO { err, cause }); - } - } - - return Err(TryMoveError::RecoveredIO(cause)); - } - - Ok(()) -} - -impl LocalDirectoryEnvironment { - pub fn root_path(&self) -> &Utf8Path { - &self.environment_path - } - - pub fn entries_path(&self) -> Utf8PathBuf { - self.environment_path.join(ENTRIES_PATH) - } - - pub fn uri_path>(&self, uri: S) -> Utf8PathBuf { - self.environment_path.join(path_encode_uri(uri)) - } - - pub fn versions_path>(&self, uri: S) -> Utf8PathBuf { - let mut p = self.uri_path(uri); - p.push(VERSIONS_PATH); - p - } - - pub fn project_path, T: AsRef>(&self, uri: S, version: T) -> Utf8PathBuf { - let mut p = self.uri_path(uri); - p.push(format!("{}.kpar", version.as_ref())); - p - } -} - -#[derive(Error, Debug)] -pub enum LocalReadError { - #[error("failed to read project list file `entries.txt`: {0}")] - ProjectListFileRead(io::Error), - #[error("failed to read project versions file `versions.txt`: {0}")] - ProjectVersionsFileRead(io::Error), - #[error(transparent)] - Io(#[from] Box), -} - -impl From for LocalReadError { - fn from(v: FsIoError) -> Self { - Self::Io(Box::new(v)) - } -} - -impl ReadEnvironment for LocalDirectoryEnvironment { - type ReadError = LocalReadError; - - type UriIter = std::iter::Map< - io::Lines>, - fn(Result) -> Result, - >; - - fn uris(&self) -> Result { - Ok(BufReader::new(wrapfs::File::open(self.entries_path())?) - .lines() - .map(|x| match x { - Ok(line) => Ok(line), - Err(err) => Err(LocalReadError::ProjectListFileRead(err)), - })) - } - - type VersionIter = std::iter::Map< - io::Lines>, - fn(Result) -> Result, - >; - - fn versions>(&self, uri: S) -> Result { - let vp = self.versions_path(uri); - - // TODO: Better refactor the interface to return a - // maybe (similar to *Map::get) - if !vp.exists() { - if let Some(vpp) = vp.parent() - && !vpp.exists() - { - wrapfs::create_dir(vpp)?; - } - wrapfs::File::create(&vp)?; - } - - Ok(BufReader::new(wrapfs::File::open(&vp)?) - .lines() - .map(|x| match x { - Ok(line) => Ok(line), - Err(err) => Err(LocalReadError::ProjectVersionsFileRead(err)), - })) - } - - type InterchangeProjectRead = LocalSrcProject; - - fn get_project, T: AsRef>( - &self, - uri: S, - version: T, - ) -> Result { - let path = self.project_path(&uri, version); - let project_path = wrapfs::canonicalize(path)?; - let root_path = wrapfs::canonicalize(self.root_path())?; - let nominal_path = root_path - .parent() - .and_then(|r| project_path.strip_prefix(r).ok()) - .map(|p| p.to_path_buf()); - - Ok(LocalSrcProject { - nominal_path, - project_path, - }) - } -} - -#[derive(Error, Debug)] -pub enum LocalWriteError { - #[error(transparent)] - Deserialize(#[from] ProjectDeserializationError), - #[error(transparent)] - Serialize(#[from] ProjectSerializationError), - #[error("path error: {0}")] - Path(#[from] PathError), - #[error("already exists: {0}")] - AlreadyExists(String), - #[error(transparent)] - Io(#[from] Box), - #[error(transparent)] - TryMove(#[from] TryMoveError), - #[error(transparent)] - LocalRead(LocalReadError), - #[error( - "cannot construct a relative path from the workspace/project - directory to one of its dependencies' directory:\n\ - {0}" - )] - ImpossibleRelativePath(#[from] RelativizePathError), - #[error("project is missing metadata file `.meta.json`")] - MissingMeta, -} - -impl From for LocalWriteError { - fn from(v: FsIoError) -> Self { - Self::Io(Box::new(v)) - } -} - -impl From for LocalWriteError { - fn from(value: LocalReadError) -> Self { - match value { - LocalReadError::Io(error) => Self::Io(error), - e @ (LocalReadError::ProjectListFileRead(_) - | LocalReadError::ProjectVersionsFileRead(_)) => Self::LocalRead(e), - } - } -} - -impl From for LocalWriteError { - fn from(value: LocalSrcError) -> Self { - match value { - LocalSrcError::Deserialize(error) => LocalWriteError::Deserialize(error), - LocalSrcError::Path(path_error) => LocalWriteError::Path(path_error), - LocalSrcError::AlreadyExists(msg) => LocalWriteError::AlreadyExists(msg), - LocalSrcError::Io(e) => LocalWriteError::Io(e), - LocalSrcError::Serialize(error) => Self::Serialize(error), - LocalSrcError::ImpossibleRelativePath(err) => Self::ImpossibleRelativePath(err), - LocalSrcError::MissingMeta => LocalWriteError::MissingMeta, - } - } -} - -fn add_line_temp>( - reader: R, - line: S, -) -> Result { - let mut temp_file = NamedUtf8TempFile::new().map_err(FsIoError::CreateTempFile)?; - - let mut line_added = false; - for this_line in BufReader::new(reader).lines() { - let this_line = this_line.map_err(|e| FsIoError::ReadFile(temp_file.to_path_buf(), e))?; - - if !line_added && line.as_ref() < this_line.as_str() { - writeln!(temp_file, "{}", line.as_ref()) - .map_err(|e| FsIoError::WriteFile(temp_file.path().into(), e))?; - line_added = true; - } - - writeln!(temp_file, "{}", this_line) - .map_err(|e| FsIoError::WriteFile(temp_file.path().into(), e))?; - - if line.as_ref() == this_line { - line_added = true; - } - } - - if !line_added { - writeln!(temp_file, "{}", line.as_ref()) - .map_err(|e| FsIoError::WriteFile(temp_file.path().into(), e))?; - } - - Ok(temp_file) -} - -fn singleton_line_temp>(line: S) -> Result { - let mut temp_file = NamedUtf8TempFile::new().map_err(FsIoError::CreateTempFile)?; - - writeln!(temp_file, "{}", line.as_ref()) - .map_err(|e| FsIoError::WriteFile(temp_file.path().into(), e))?; - - Ok(temp_file) -} - -impl WriteEnvironment for LocalDirectoryEnvironment { - type WriteError = LocalWriteError; - - type InterchangeProjectMut = LocalSrcProject; - - fn put_project, T: AsRef, F, E>( - &mut self, - uri: S, - version: T, - write_project: F, - ) -> Result> - where - F: FnOnce(&mut Self::InterchangeProjectMut) -> Result<(), E>, - { - let uri_path = self.uri_path(&uri); - let versions_path = self.versions_path(&uri); - - let entries_temp = add_line_temp( - wrapfs::File::open(self.entries_path()).map_err(LocalWriteError::from)?, - &uri, - )?; - - let versions_temp = if !versions_path.exists() { - singleton_line_temp(version.as_ref()) - } else { - let current_versions_f = - wrapfs::File::open(&versions_path).map_err(LocalWriteError::from)?; - add_line_temp(current_versions_f, version.as_ref()) - }?; - - let project_temp = camino_tempfile::tempdir() - .map_err(|e| LocalWriteError::from(FsIoError::MkTempDir(e)))?; - - let mut tentative_project = LocalSrcProject { - nominal_path: None, - project_path: project_temp.path().to_path_buf(), - }; - - write_project(&mut tentative_project).map_err(PutProjectError::Callback)?; - - // Project write was successful - - if !uri_path.exists() { - wrapfs::create_dir(&uri_path).map_err(LocalWriteError::from)?; - } - - // Move existing stuff out of the way - let project_path = self.project_path(&uri, &version); - - // TODO: Handle catastrophic errors differently - try_move_files(&vec![ - (project_temp.path(), &project_path), - (versions_temp.path(), &versions_path), - (entries_temp.path(), &self.entries_path()), - ]) - .map_err(LocalWriteError::from)?; - - Ok(LocalSrcProject { - nominal_path: project_path - .parent() - .and_then(|p| p.strip_prefix(self.root_path()).ok()) - .map(|p| p.to_path_buf()), - project_path, - }) - } - - fn del_project_version, T: AsRef>( - &mut self, - uri: S, - version: T, - ) -> Result<(), Self::WriteError> { - let mut versions_temp = - NamedUtf8TempFile::with_suffix("versions.txt").map_err(FsIoError::CreateTempFile)?; - - let versions_path = self.versions_path(&uri); - let mut found = false; - let mut empty = true; - - // I think this may be needed on Windows in order to drop the - // file handle before overwriting - { - let current_versions_f = BufReader::new(wrapfs::File::open(&versions_path)?); - for version_line_ in current_versions_f.lines() { - let version_line = version_line_ - .map_err(|e| FsIoError::ReadFile(versions_path.to_path_buf(), e))?; - - if version.as_ref() != version_line { - writeln!(versions_temp, "{}", version_line) - .map_err(|e| FsIoError::WriteFile(versions_path.clone(), e))?; - - empty = false; - } else { - found = true; - } - } - } - - if found { - let project: LocalSrcProject = self - .get_project(&uri, version) - .map_err(LocalWriteError::from)?; - - // TODO: Add better error messages for catastrophic errors - if let Err(err) = try_remove_files( - project - .get_source_paths()? - .into_iter() - .chain(vec![project.info_path(), project.meta_path()]), - ) { - match err { - TryMoveError::CatastrophicIO { .. } => { - // Censor the version if a partial delete happened, better pretend - // like it does not exist than to pretend like a broken - // package is properly installed - wrapfs::copy(versions_temp.path(), &versions_path)?; - return Err(err.into()); - } - TryMoveError::RecoveredIO(_) => return Err(LocalWriteError::from(err)), - } - } - - wrapfs::copy(versions_temp.path(), &versions_path)?; - - remove_empty_dirs(project.project_path)?; - if empty { - let current_uris_: Result, LocalReadError> = self.uris()?.collect(); - let current_uris: Vec = current_uris_?; - let entries_path = self.entries_path(); - let mut f = io::BufWriter::new(wrapfs::File::create(&entries_path)?); - for existing_uri in current_uris { - if uri.as_ref() != existing_uri { - writeln!(f, "{}", existing_uri) - .map_err(|e| FsIoError::WriteFile(entries_path.clone(), e))?; - } - } - wrapfs::remove_file(versions_path)?; - remove_dir_if_empty(self.uri_path(&uri))?; - } - } - - Ok(()) - } - - fn del_uri>(&mut self, uri: S) -> Result<(), Self::WriteError> { - let current_uris_: Result, LocalReadError> = self.uris()?.collect(); - let current_uris: Vec = current_uris_?; - - if current_uris.contains(&uri.as_ref().to_string()) { - for version_ in self.versions(&uri)? { - let version: String = version_?; - self.del_project_version(&uri, &version)?; - } - } - - Ok(()) - } -} - -#[derive(Debug, Error)] -pub enum ResolvedManifestError { - #[error(transparent)] - ResolutionError(#[from] ResolutionError), - #[error("too many dependencies, unable to convert to i64: {0}")] - TooManyDependencies(TryFromIntError), - #[error(transparent)] - LocalSources(#[from] LocalSourcesError), - #[error(transparent)] - Canonicalization(#[from] Box), -} - -impl Lock { - pub fn to_resolved_manifest>( - &self, - env: &LocalDirectoryEnvironment, - root_path: P, - ) -> Result { - let resolved_projects = self.resolve_projects(env)?; - - let indices = resolved_projects - .iter() - .map(|(p, _)| p) - .enumerate() - .flat_map(|(num, p)| p.identifiers.iter().map(move |iri| (iri.clone(), num))) - .map(|(iri, num)| i64::try_from(num).map(|num| (iri, num))) - .collect::, _>>() - .map_err(ResolvedManifestError::TooManyDependencies)?; - let indices = HashMap::::from_iter(indices); - - let mut projects = vec![]; - for (project, storage) in resolved_projects { - let usages = project - .usages - .iter() - .filter_map(|usage| indices.get(&usage.resource)) - .copied() - .collect(); - let purl = project.get_package_url(); - let publisher = purl - .as_ref() - .and_then(|p| p.namespace().map(|ns| ns.to_owned())); - let name = purl.as_ref().map(|p| p.name().to_owned()).or(project.name); - - if let Some(storage) = storage { - let directory = storage.root_path().to_owned(); - projects.push(ResolvedProject { - publisher, - name, - location: ResolvedLocation::Directory(directory), - usages, - }); - } else if let [Source::Editable { editable }, ..] = project.sources.as_slice() { - let project_path = root_path.as_ref().join(editable.as_str()); - let editable_project = LocalSrcProject { - nominal_path: None, - project_path: wrapfs::canonicalize(project_path)?, - }; - let files = do_sources_local_src_project_no_deps(&editable_project, true)? - .into_iter() - .collect(); - projects.push(ResolvedProject { - publisher, - name, - location: ResolvedLocation::Files(files), - usages, - }); - } - } - - Ok(ResolvedManifest { projects }) - } -} - -#[derive(Debug)] -pub struct ResolvedManifest { - pub projects: Vec, -} - -impl Display for ResolvedManifest { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.to_toml()) - } -} - -impl ResolvedManifest { - pub fn to_toml(&self) -> DocumentMut { - let mut doc = DocumentMut::new(); - let mut projects = ArrayOfTables::new(); - for project in &self.projects { - projects.push(project.to_toml()); - } - doc.insert("project", Item::ArrayOfTables(projects)); - - doc - } -} - -#[derive(Debug)] -pub enum ResolvedLocation { - Directory(Utf8PathBuf), - Files(Vec), -} - -#[derive(Debug)] -pub struct ResolvedProject { - pub publisher: Option, - pub name: Option, - pub location: ResolvedLocation, - pub usages: Vec, -} - -impl ResolvedProject { - pub fn to_toml(&self) -> Table { - let mut table = Table::new(); - if let Some(publisher) = &self.publisher { - table.insert("publisher", value(publisher)); - } - if let Some(name) = &self.name { - table.insert("name", value(name)); - } - match &self.location { - ResolvedLocation::Directory(dir) => { - table.insert("directory", value(dir.as_str())); - } - ResolvedLocation::Files(files) => { - if !files.is_empty() { - table.insert( - "files", - value(multiline_array( - files.iter().map(|f| Value::from(f.as_str())), - )), - ); - } - } - } - if !self.usages.is_empty() { - let usages = Array::from_iter(self.usages.iter().copied().map(Value::from)); - table.insert("usages", value(usages)); - } - table - } -} diff --git a/core/src/env/local_directory/manifest.rs b/core/src/env/local_directory/manifest.rs new file mode 100644 index 00000000..3a6213b6 --- /dev/null +++ b/core/src/env/local_directory/manifest.rs @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: © 2025 Sysand contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::{collections::HashMap, fmt::Display, num::TryFromIntError}; + +use camino::{Utf8Path, Utf8PathBuf}; +use thiserror::Error; +use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, Value, value}; + +use crate::{ + commands::sources::{LocalSourcesError, do_sources_local_src_project_no_deps}, + env::local_directory::{LocalDirectoryEnvironment, LocalReadError}, + lock::{Lock, ResolutionError, Source, multiline_array}, + project::{ + local_src::LocalSrcProject, + utils::{FsIoError, wrapfs}, + }, +}; + +#[derive(Debug, Error)] +pub enum ResolvedManifestError { + #[error(transparent)] + ResolutionError(#[from] ResolutionError), + #[error("too many dependencies, unable to convert to i64: {0}")] + TooManyDependencies(TryFromIntError), + #[error(transparent)] + LocalSources(#[from] LocalSourcesError), + #[error(transparent)] + Canonicalization(#[from] Box), +} + +impl Lock { + pub fn to_resolved_manifest>( + &self, + env: &LocalDirectoryEnvironment, + root_path: P, + ) -> Result { + let resolved_projects = self.resolve_projects(env)?; + + let indices = resolved_projects + .iter() + .map(|(p, _)| p) + .enumerate() + .flat_map(|(num, p)| p.identifiers.iter().map(move |iri| (iri.clone(), num))) + .map(|(iri, num)| i64::try_from(num).map(|num| (iri, num))) + .collect::, _>>() + .map_err(ResolvedManifestError::TooManyDependencies)?; + let indices = HashMap::::from_iter(indices); + + let mut projects = vec![]; + for (project, storage) in resolved_projects { + let usages = project + .usages + .iter() + .filter_map(|usage| indices.get(&usage.resource)) + .copied() + .collect(); + let purl = project.get_package_url(); + let publisher = purl + .as_ref() + .and_then(|p| p.namespace().map(|ns| ns.to_owned())); + let name = purl.as_ref().map(|p| p.name().to_owned()).or(project.name); + + if let Some(storage) = storage { + let directory = storage.root_path().to_owned(); + projects.push(ResolvedProject { + publisher, + name, + location: ResolvedLocation::Directory(directory), + usages, + }); + } else if let [Source::Editable { editable }, ..] = project.sources.as_slice() { + let project_path = root_path.as_ref().join(editable.as_str()); + let editable_project = LocalSrcProject { + nominal_path: None, + project_path: wrapfs::canonicalize(project_path)?, + }; + let files = do_sources_local_src_project_no_deps(&editable_project, true)? + .into_iter() + .collect(); + projects.push(ResolvedProject { + publisher, + name, + location: ResolvedLocation::Files(files), + usages, + }); + } + } + + Ok(ResolvedManifest { projects }) + } +} + +#[derive(Debug)] +pub struct ResolvedManifest { + pub projects: Vec, +} + +impl Display for ResolvedManifest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_toml()) + } +} + +impl ResolvedManifest { + pub fn to_toml(&self) -> DocumentMut { + let mut doc = DocumentMut::new(); + let mut projects = ArrayOfTables::new(); + for project in &self.projects { + projects.push(project.to_toml()); + } + doc.insert("project", Item::ArrayOfTables(projects)); + + doc + } +} + +#[derive(Debug)] +pub enum ResolvedLocation { + Directory(Utf8PathBuf), + Files(Vec), +} + +#[derive(Debug)] +pub struct ResolvedProject { + pub publisher: Option, + pub name: Option, + pub location: ResolvedLocation, + pub usages: Vec, +} + +impl ResolvedProject { + pub fn to_toml(&self) -> Table { + let mut table = Table::new(); + if let Some(publisher) = &self.publisher { + table.insert("publisher", value(publisher)); + } + if let Some(name) = &self.name { + table.insert("name", value(name)); + } + match &self.location { + ResolvedLocation::Directory(dir) => { + table.insert("directory", value(dir.as_str())); + } + ResolvedLocation::Files(files) => { + if !files.is_empty() { + table.insert( + "files", + value(multiline_array( + files.iter().map(|f| Value::from(f.as_str())), + )), + ); + } + } + } + if !self.usages.is_empty() { + let usages = Array::from_iter(self.usages.iter().copied().map(Value::from)); + table.insert("usages", value(usages)); + } + table + } +} diff --git a/core/src/env/local_directory/mod.rs b/core/src/env/local_directory/mod.rs new file mode 100644 index 00000000..5877bc42 --- /dev/null +++ b/core/src/env/local_directory/mod.rs @@ -0,0 +1,361 @@ +// SPDX-FileCopyrightText: © 2025 Sysand contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::io::{self, BufRead, BufReader, Write}; + +use camino::{Utf8Path, Utf8PathBuf}; +use camino_tempfile::NamedUtf8TempFile; +use thiserror::Error; + +use crate::{ + env::{PutProjectError, ReadEnvironment, WriteEnvironment}, + project::{ + local_src::{LocalSrcError, LocalSrcProject, PathError}, + utils::{ + FsIoError, ProjectDeserializationError, ProjectSerializationError, RelativizePathError, + ToPathBuf, wrapfs, + }, + }, +}; + +pub mod manifest; +mod utils; + +use utils::{ + TryMoveError, add_line_temp, path_encode_uri, remove_dir_if_empty, remove_empty_dirs, + singleton_line_temp, try_move_files, try_remove_files, +}; + +#[derive(Clone, Debug)] +pub struct LocalDirectoryEnvironment { + pub environment_path: Utf8PathBuf, +} + +pub const DEFAULT_ENV_NAME: &str = "sysand_env"; + +pub const DEFAULT_MANIFEST_NAME: &str = "current.toml"; + +pub const ENTRIES_PATH: &str = "entries.txt"; +pub const VERSIONS_PATH: &str = "versions.txt"; + +impl LocalDirectoryEnvironment { + pub fn root_path(&self) -> &Utf8Path { + &self.environment_path + } + + pub fn entries_path(&self) -> Utf8PathBuf { + self.environment_path.join(ENTRIES_PATH) + } + + pub fn uri_path>(&self, uri: S) -> Utf8PathBuf { + self.environment_path.join(path_encode_uri(uri)) + } + + pub fn versions_path>(&self, uri: S) -> Utf8PathBuf { + let mut p = self.uri_path(uri); + p.push(VERSIONS_PATH); + p + } + + pub fn project_path, T: AsRef>(&self, uri: S, version: T) -> Utf8PathBuf { + let mut p = self.uri_path(uri); + p.push(format!("{}.kpar", version.as_ref())); + p + } +} + +#[derive(Error, Debug)] +pub enum LocalReadError { + #[error("failed to read project list file `entries.txt`: {0}")] + ProjectListFileRead(io::Error), + #[error("failed to read project versions file `versions.txt`: {0}")] + ProjectVersionsFileRead(io::Error), + #[error(transparent)] + Io(#[from] Box), +} + +impl From for LocalReadError { + fn from(v: FsIoError) -> Self { + Self::Io(Box::new(v)) + } +} + +impl ReadEnvironment for LocalDirectoryEnvironment { + type ReadError = LocalReadError; + + type UriIter = std::iter::Map< + io::Lines>, + fn(Result) -> Result, + >; + + fn uris(&self) -> Result { + Ok(BufReader::new(wrapfs::File::open(self.entries_path())?) + .lines() + .map(|x| match x { + Ok(line) => Ok(line), + Err(err) => Err(LocalReadError::ProjectListFileRead(err)), + })) + } + + type VersionIter = std::iter::Map< + io::Lines>, + fn(Result) -> Result, + >; + + fn versions>(&self, uri: S) -> Result { + let vp = self.versions_path(uri); + + // TODO: Better refactor the interface to return a + // maybe (similar to *Map::get) + if !vp.exists() { + if let Some(vpp) = vp.parent() + && !vpp.exists() + { + wrapfs::create_dir(vpp)?; + } + wrapfs::File::create(&vp)?; + } + + Ok(BufReader::new(wrapfs::File::open(&vp)?) + .lines() + .map(|x| match x { + Ok(line) => Ok(line), + Err(err) => Err(LocalReadError::ProjectVersionsFileRead(err)), + })) + } + + type InterchangeProjectRead = LocalSrcProject; + + fn get_project, T: AsRef>( + &self, + uri: S, + version: T, + ) -> Result { + let path = self.project_path(&uri, version); + let project_path = wrapfs::canonicalize(path)?; + let root_path = wrapfs::canonicalize(self.root_path())?; + let nominal_path = root_path + .parent() + .and_then(|r| project_path.strip_prefix(r).ok()) + .map(|p| p.to_path_buf()); + + Ok(LocalSrcProject { + nominal_path, + project_path, + }) + } +} + +#[derive(Error, Debug)] +pub enum LocalWriteError { + #[error(transparent)] + Deserialize(#[from] ProjectDeserializationError), + #[error(transparent)] + Serialize(#[from] ProjectSerializationError), + #[error("path error: {0}")] + Path(#[from] PathError), + #[error("already exists: {0}")] + AlreadyExists(String), + #[error(transparent)] + Io(#[from] Box), + #[error(transparent)] + TryMove(#[from] TryMoveError), + #[error(transparent)] + LocalRead(LocalReadError), + #[error( + "cannot construct a relative path from the workspace/project + directory to one of its dependencies' directory:\n\ + {0}" + )] + ImpossibleRelativePath(#[from] RelativizePathError), + #[error("project is missing metadata file `.meta.json`")] + MissingMeta, +} + +impl From for LocalWriteError { + fn from(v: FsIoError) -> Self { + Self::Io(Box::new(v)) + } +} + +impl From for LocalWriteError { + fn from(value: LocalReadError) -> Self { + match value { + LocalReadError::Io(error) => Self::Io(error), + e @ (LocalReadError::ProjectListFileRead(_) + | LocalReadError::ProjectVersionsFileRead(_)) => Self::LocalRead(e), + } + } +} + +impl From for LocalWriteError { + fn from(value: LocalSrcError) -> Self { + match value { + LocalSrcError::Deserialize(error) => LocalWriteError::Deserialize(error), + LocalSrcError::Path(path_error) => LocalWriteError::Path(path_error), + LocalSrcError::AlreadyExists(msg) => LocalWriteError::AlreadyExists(msg), + LocalSrcError::Io(e) => LocalWriteError::Io(e), + LocalSrcError::Serialize(error) => Self::Serialize(error), + LocalSrcError::ImpossibleRelativePath(err) => Self::ImpossibleRelativePath(err), + LocalSrcError::MissingMeta => LocalWriteError::MissingMeta, + } + } +} + +impl WriteEnvironment for LocalDirectoryEnvironment { + type WriteError = LocalWriteError; + + type InterchangeProjectMut = LocalSrcProject; + + fn put_project, T: AsRef, F, E>( + &mut self, + uri: S, + version: T, + write_project: F, + ) -> Result> + where + F: FnOnce(&mut Self::InterchangeProjectMut) -> Result<(), E>, + { + let uri_path = self.uri_path(&uri); + let versions_path = self.versions_path(&uri); + + let entries_temp = add_line_temp( + wrapfs::File::open(self.entries_path()).map_err(LocalWriteError::from)?, + &uri, + )?; + + let versions_temp = if !versions_path.exists() { + singleton_line_temp(version.as_ref()) + } else { + let current_versions_f = + wrapfs::File::open(&versions_path).map_err(LocalWriteError::from)?; + add_line_temp(current_versions_f, version.as_ref()) + }?; + + let project_temp = camino_tempfile::tempdir() + .map_err(|e| LocalWriteError::from(FsIoError::MkTempDir(e)))?; + + let mut tentative_project = LocalSrcProject { + nominal_path: None, + project_path: project_temp.path().to_path_buf(), + }; + + write_project(&mut tentative_project).map_err(PutProjectError::Callback)?; + + // Project write was successful + + if !uri_path.exists() { + wrapfs::create_dir(&uri_path).map_err(LocalWriteError::from)?; + } + + // Move existing stuff out of the way + let project_path = self.project_path(&uri, &version); + + // TODO: Handle catastrophic errors differently + try_move_files(&vec![ + (project_temp.path(), &project_path), + (versions_temp.path(), &versions_path), + (entries_temp.path(), &self.entries_path()), + ]) + .map_err(LocalWriteError::from)?; + + Ok(LocalSrcProject { + nominal_path: project_path + .parent() + .and_then(|p| p.strip_prefix(self.root_path()).ok()) + .map(|p| p.to_path_buf()), + project_path, + }) + } + + fn del_project_version, T: AsRef>( + &mut self, + uri: S, + version: T, + ) -> Result<(), Self::WriteError> { + let mut versions_temp = + NamedUtf8TempFile::with_suffix("versions.txt").map_err(FsIoError::CreateTempFile)?; + + let versions_path = self.versions_path(&uri); + let mut found = false; + let mut empty = true; + + // I think this may be needed on Windows in order to drop the + // file handle before overwriting + { + let current_versions_f = BufReader::new(wrapfs::File::open(&versions_path)?); + for version_line_ in current_versions_f.lines() { + let version_line = version_line_ + .map_err(|e| FsIoError::ReadFile(versions_path.to_path_buf(), e))?; + + if version.as_ref() != version_line { + writeln!(versions_temp, "{}", version_line) + .map_err(|e| FsIoError::WriteFile(versions_path.clone(), e))?; + + empty = false; + } else { + found = true; + } + } + } + + if found { + let project: LocalSrcProject = self + .get_project(&uri, version) + .map_err(LocalWriteError::from)?; + + // TODO: Add better error messages for catastrophic errors + if let Err(err) = try_remove_files( + project + .get_source_paths()? + .into_iter() + .chain(vec![project.info_path(), project.meta_path()]), + ) { + match err { + TryMoveError::CatastrophicIO { .. } => { + // Censor the version if a partial delete happened, better pretend + // like it does not exist than to pretend like a broken + // package is properly installed + wrapfs::copy(versions_temp.path(), &versions_path)?; + return Err(err.into()); + } + TryMoveError::RecoveredIO(_) => return Err(LocalWriteError::from(err)), + } + } + + wrapfs::copy(versions_temp.path(), &versions_path)?; + + remove_empty_dirs(project.project_path)?; + if empty { + let current_uris_: Result, LocalReadError> = self.uris()?.collect(); + let current_uris: Vec = current_uris_?; + let entries_path = self.entries_path(); + let mut f = io::BufWriter::new(wrapfs::File::create(&entries_path)?); + for existing_uri in current_uris { + if uri.as_ref() != existing_uri { + writeln!(f, "{}", existing_uri) + .map_err(|e| FsIoError::WriteFile(entries_path.clone(), e))?; + } + } + wrapfs::remove_file(versions_path)?; + remove_dir_if_empty(self.uri_path(&uri))?; + } + } + + Ok(()) + } + + fn del_uri>(&mut self, uri: S) -> Result<(), Self::WriteError> { + let current_uris_: Result, LocalReadError> = self.uris()?.collect(); + let current_uris: Vec = current_uris_?; + + if current_uris.contains(&uri.as_ref().to_string()) { + for version_ in self.versions(&uri)? { + let version: String = version_?; + self.del_project_version(&uri, &version)?; + } + } + + Ok(()) + } +} diff --git a/core/src/env/local_directory/utils.rs b/core/src/env/local_directory/utils.rs new file mode 100644 index 00000000..cce7b104 --- /dev/null +++ b/core/src/env/local_directory/utils.rs @@ -0,0 +1,309 @@ +// SPDX-FileCopyrightText: © 2025 Sysand contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::{ + fs, + io::{self, BufRead, BufReader, Read, Write}, +}; + +use camino::{Utf8Path, Utf8PathBuf}; +use camino_tempfile::NamedUtf8TempFile; +use sha2::Sha256; +use thiserror::Error; + +use crate::{ + env::{local_directory::LocalWriteError, segment_uri_generic}, + project::utils::{FsIoError, ToPathBuf, wrapfs}, +}; + +/// Get a relative path corresponding to the given `uri` +pub fn path_encode_uri>(uri: S) -> Utf8PathBuf { + let mut result = Utf8PathBuf::new(); + for segment in segment_uri_generic::(uri) { + result.push(segment); + } + + result +} + +pub fn remove_dir_if_empty>(path: P) -> Result<(), FsIoError> { + match fs::remove_dir(path.as_ref()) { + Err(err) if err.kind() == io::ErrorKind::DirectoryNotEmpty => Ok(()), + r => r.map_err(|e| FsIoError::RmDir(path.to_path_buf(), e)), + } +} + +pub fn remove_empty_dirs>(path: P) -> Result<(), FsIoError> { + let mut dirs: Vec<_> = walkdir::WalkDir::new(path.as_ref()) + .into_iter() + .filter_map(|e| e.ok()) + .filter_map(|e| { + e.file_type() + .is_dir() + .then(|| Utf8PathBuf::from_path_buf(e.into_path()).ok()) + .flatten() + }) + .collect(); + + dirs.sort_by(|a, b| b.cmp(a)); + + for dir in dirs { + remove_dir_if_empty(&dir)?; + } + + Ok(()) +} + +#[derive(Error, Debug)] +pub enum TryMoveError { + #[error("recovered from failure: {0}")] + RecoveredIO(Box), + #[error( + "failed and may have left the directory in inconsistent state:\n{err}\nwhich was caused by:\n{cause}" + )] + CatastrophicIO { + err: Box, + cause: Box, + }, +} + +pub fn try_remove_files, I: Iterator>( + paths: I, +) -> Result<(), TryMoveError> { + let tempdir = camino_tempfile::tempdir() + .map_err(|e| TryMoveError::RecoveredIO(FsIoError::CreateTempFile(e).into()))?; + let mut moved: Vec = vec![]; + + for (i, path) in paths.enumerate() { + match move_fs_item(&path, tempdir.path().join(i.to_string())) { + Ok(_) => { + moved.push(path.to_path_buf()); + } + Err(cause) => { + // NOTE: This dance is to bypass the fact that std::io::error is not Clone-eable... + let mut catastrophic_error = None; + for (j, recover) in moved.iter().enumerate() { + if let Err(err) = move_fs_item(tempdir.path().join(j.to_string()), recover) { + catastrophic_error = Some(err); + break; + } + } + + if let Some(err) = catastrophic_error { + return Err(TryMoveError::CatastrophicIO { err, cause }); + } else { + return Err(TryMoveError::RecoveredIO(cause)); + } + } + } + } + + Ok(()) +} + +// Recursively copy a directory from `src` to `dst`. +// Assumes that all parents of `dst` exist. +fn copy_dir_recursive, Q: AsRef>( + src: P, + dst: Q, +) -> Result<(), Box> { + wrapfs::create_dir(&dst)?; + + for entry_result in wrapfs::read_dir(&src)? { + let entry = entry_result.map_err(|e| FsIoError::ReadDir(src.to_path_buf(), e))?; + let file_type = entry + .file_type() + .map_err(|e| FsIoError::ReadDir(src.to_path_buf(), e))?; + let src_path = entry.path(); + let dst_path = dst.as_ref().join(entry.file_name()); + + if file_type.is_dir() { + copy_dir_recursive(src_path, dst_path)?; + } else { + wrapfs::copy(src_path, dst_path)?; + } + } + + Ok(()) +} + +// Rename/move a file or directory from `src` to `dst`. +fn move_fs_item, Q: AsRef>( + src: P, + dst: Q, +) -> Result<(), Box> { + match fs::rename(src.as_ref(), dst.as_ref()) { + Ok(_) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::CrossesDevices => { + let metadata = wrapfs::metadata(&src)?; + if metadata.is_dir() { + copy_dir_recursive(&src, &dst)?; + wrapfs::remove_dir_all(&src)?; + } else { + wrapfs::copy(&src, &dst)?; + wrapfs::remove_file(&src)?; + } + Ok(()) + } + Err(e) => Err(FsIoError::Move(src.to_path_buf(), dst.to_path_buf(), e))?, + } +} + +pub fn try_move_files(paths: &Vec<(&Utf8Path, &Utf8Path)>) -> Result<(), TryMoveError> { + let tempdir = camino_tempfile::tempdir() + .map_err(|e| TryMoveError::RecoveredIO(FsIoError::CreateTempFile(e).into()))?; + + let mut last_err = None; + + // move source files out of the way + for (i, (path, _)) in paths.iter().enumerate() { + let src_path = tempdir.path().join(format!("src_{}", i)); + if let Err(e) = move_fs_item(path, src_path) { + last_err = Some(e); + break; + } + } + + // Recover moved files in case of failure + if let Some(cause) = last_err { + for (i, (path, _)) in paths.iter().enumerate() { + let src_path = tempdir.path().join(format!("src_{}", i)); + + if src_path.exists() + && let Err(err) = move_fs_item(src_path, path) + { + return Err(TryMoveError::CatastrophicIO { err, cause }); + } + } + + return Err(TryMoveError::RecoveredIO(cause)); + } + + let mut last_err = None; + + // Move target files out of the way + for (i, (_, path)) in paths.iter().enumerate() { + if path.exists() { + let trg_path = tempdir.path().join(format!("trg_{}", i)); + if let Err(e) = move_fs_item(path, trg_path) { + last_err = Some(e); + break; + } + } + } + + // Recover moved files in case of failure + if let Some(cause) = last_err { + for (i, (_, path)) in paths.iter().enumerate() { + let trg_path = tempdir.path().join(format!("trg_{}", i)); + + if trg_path.exists() + && let Err(err) = move_fs_item(trg_path, path) + { + return Err(TryMoveError::CatastrophicIO { err, cause }); + } + } + + for (i, (path, _)) in paths.iter().enumerate() { + let src_path = tempdir.path().join(format!("src_{}", i)); + + if src_path.exists() + && let Err(err) = move_fs_item(src_path, path) + { + return Err(TryMoveError::CatastrophicIO { err, cause }); + } + } + + return Err(TryMoveError::RecoveredIO(cause)); + } + + let mut last_err = None; + + // Try moving files to destination + for (i, (_, target)) in paths.iter().enumerate() { + let src_path = tempdir.path().join(format!("src_{}", i)); + + if let Err(e) = move_fs_item(src_path, target) { + last_err = Some(e); + break; + } + } + + // Recover moved files in case of failure + if let Some(cause) = last_err { + for (i, (_, path)) in paths.iter().enumerate() { + let src_path = tempdir.path().join(format!("src_{}", i)); + + if path.exists() + && let Err(err) = move_fs_item(path, src_path) + { + return Err(TryMoveError::CatastrophicIO { err, cause }); + } + } + + for (i, (_, path)) in paths.iter().enumerate() { + let trg_path = tempdir.path().join(format!("trg_{}", i)); + + if trg_path.exists() + && let Err(err) = move_fs_item(trg_path, path) + { + return Err(TryMoveError::CatastrophicIO { err, cause }); + } + } + + for (i, (path, _)) in paths.iter().enumerate() { + let src_path = tempdir.path().join(format!("src_{}", i)); + + if src_path.exists() + && let Err(err) = move_fs_item(src_path, path) + { + return Err(TryMoveError::CatastrophicIO { err, cause }); + } + } + + return Err(TryMoveError::RecoveredIO(cause)); + } + + Ok(()) +} + +pub fn add_line_temp>( + reader: R, + line: S, +) -> Result { + let mut temp_file = NamedUtf8TempFile::new().map_err(FsIoError::CreateTempFile)?; + + let mut line_added = false; + for this_line in BufReader::new(reader).lines() { + let this_line = this_line.map_err(|e| FsIoError::ReadFile(temp_file.to_path_buf(), e))?; + + if !line_added && line.as_ref() < this_line.as_str() { + writeln!(temp_file, "{}", line.as_ref()) + .map_err(|e| FsIoError::WriteFile(temp_file.path().into(), e))?; + line_added = true; + } + + writeln!(temp_file, "{}", this_line) + .map_err(|e| FsIoError::WriteFile(temp_file.path().into(), e))?; + + if line.as_ref() == this_line { + line_added = true; + } + } + + if !line_added { + writeln!(temp_file, "{}", line.as_ref()) + .map_err(|e| FsIoError::WriteFile(temp_file.path().into(), e))?; + } + + Ok(temp_file) +} + +pub fn singleton_line_temp>(line: S) -> Result { + let mut temp_file = NamedUtf8TempFile::new().map_err(FsIoError::CreateTempFile)?; + + writeln!(temp_file, "{}", line.as_ref()) + .map_err(|e| FsIoError::WriteFile(temp_file.path().into(), e))?; + + Ok(temp_file) +} From 248c331062e4154df29678e12e959cf19e351a31 Mon Sep 17 00:00:00 2001 From: "victor.linroth.sensmetry" Date: Wed, 11 Feb 2026 12:51:52 +0100 Subject: [PATCH 04/14] Deserialize empty arrays. Signed-off-by: victor.linroth.sensmetry --- core/src/env/local_directory/manifest.rs | 16 ++++++---------- sysand/tests/cli_sync.rs | 3 +++ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/core/src/env/local_directory/manifest.rs b/core/src/env/local_directory/manifest.rs index 3a6213b6..c2ed2517 100644 --- a/core/src/env/local_directory/manifest.rs +++ b/core/src/env/local_directory/manifest.rs @@ -143,20 +143,16 @@ impl ResolvedProject { table.insert("directory", value(dir.as_str())); } ResolvedLocation::Files(files) => { + let file_iter = files.iter().map(|f| Value::from(f.as_str())); if !files.is_empty() { - table.insert( - "files", - value(multiline_array( - files.iter().map(|f| Value::from(f.as_str())), - )), - ); + table.insert("files", value(multiline_array(file_iter))); + } else { + table.insert("files", value(Array::from_iter(file_iter))); } } } - if !self.usages.is_empty() { - let usages = Array::from_iter(self.usages.iter().copied().map(Value::from)); - table.insert("usages", value(usages)); - } + let usages = Array::from_iter(self.usages.iter().copied().map(Value::from)); + table.insert("usages", value(usages)); table } } diff --git a/sysand/tests/cli_sync.rs b/sysand/tests/cli_sync.rs index 8dee2904..d9ad6fe6 100644 --- a/sysand/tests/cli_sync.rs +++ b/sysand/tests/cli_sync.rs @@ -49,6 +49,7 @@ name = "sync_to_current" files = [ "{}/test.sysml", ] +usages = [] "#, cwd ) @@ -123,6 +124,7 @@ sources = [ r#"[[project]] name = "sync_to_local" directory = "{}/{DEFAULT_ENV_NAME}/5ddc0a2e8aaa88ac2bfc71aa0a8d08e020bceac4a90a4b72d8fb7f97ec5bfcc5/1.2.3.kpar" +usages = [] "#, cwd ) @@ -204,6 +206,7 @@ sources = [ r#"[[project]] name = "sync_to_remote" directory = "{}/{DEFAULT_ENV_NAME}/2b95cb7c6d6c08695b0e7c4b7e9d836c21de37fb9c72b0cfa26f53fd84a1b459/1.2.3.kpar" +usages = [] "#, cwd ) From 78e8997fab1be300c02775cbe02c680cb48dcbb2 Mon Sep 17 00:00:00 2001 From: "victor.linroth.sensmetry" Date: Mon, 16 Mar 2026 14:28:45 +0100 Subject: [PATCH 05/14] Go from `current.toml` -> `env.toml` and include more fields. Signed-off-by: victor.linroth.sensmetry --- .../{manifest.rs => metadata.rs} | 19 +++++++++++++++++++ core/src/env/local_directory/mod.rs | 4 ++-- sysand/src/commands/sync.rs | 4 ++-- sysand/tests/cli_sync.rs | 18 ++++++++++++------ 4 files changed, 35 insertions(+), 10 deletions(-) rename core/src/env/local_directory/{manifest.rs => metadata.rs} (89%) diff --git a/core/src/env/local_directory/manifest.rs b/core/src/env/local_directory/metadata.rs similarity index 89% rename from core/src/env/local_directory/manifest.rs rename to core/src/env/local_directory/metadata.rs index c2ed2517..35e72783 100644 --- a/core/src/env/local_directory/manifest.rs +++ b/core/src/env/local_directory/metadata.rs @@ -60,14 +60,19 @@ impl Lock { .as_ref() .and_then(|p| p.namespace().map(|ns| ns.to_owned())); let name = purl.as_ref().map(|p| p.name().to_owned()).or(project.name); + let iri = project.identifiers.first().cloned(); + let version = project.version; if let Some(storage) = storage { let directory = storage.root_path().to_owned(); projects.push(ResolvedProject { publisher, name, + iri, + version, location: ResolvedLocation::Directory(directory), usages, + editable: false, }); } else if let [Source::Editable { editable }, ..] = project.sources.as_slice() { let project_path = root_path.as_ref().join(editable.as_str()); @@ -81,8 +86,11 @@ impl Lock { projects.push(ResolvedProject { publisher, name, + iri, + version, location: ResolvedLocation::Files(files), usages, + editable: true, }); } } @@ -125,8 +133,11 @@ pub enum ResolvedLocation { pub struct ResolvedProject { pub publisher: Option, pub name: Option, + pub iri: Option, + pub version: String, pub location: ResolvedLocation, pub usages: Vec, + pub editable: bool, } impl ResolvedProject { @@ -138,6 +149,10 @@ impl ResolvedProject { if let Some(name) = &self.name { table.insert("name", value(name)); } + if let Some(iri) = &self.iri { + table.insert("iri", value(iri)); + } + table.insert("version", value(&self.version)); match &self.location { ResolvedLocation::Directory(dir) => { table.insert("directory", value(dir.as_str())); @@ -153,6 +168,10 @@ impl ResolvedProject { } let usages = Array::from_iter(self.usages.iter().copied().map(Value::from)); table.insert("usages", value(usages)); + if self.editable { + table.insert("editable", value(true)); + } + table } } diff --git a/core/src/env/local_directory/mod.rs b/core/src/env/local_directory/mod.rs index 5877bc42..63e81a95 100644 --- a/core/src/env/local_directory/mod.rs +++ b/core/src/env/local_directory/mod.rs @@ -18,7 +18,7 @@ use crate::{ }, }; -pub mod manifest; +pub mod metadata; mod utils; use utils::{ @@ -33,7 +33,7 @@ pub struct LocalDirectoryEnvironment { pub const DEFAULT_ENV_NAME: &str = "sysand_env"; -pub const DEFAULT_MANIFEST_NAME: &str = "current.toml"; +pub const DEFAULT_METADATA_NAME: &str = "env.toml"; pub const ENTRIES_PATH: &str = "entries.txt"; pub const VERSIONS_PATH: &str = "versions.txt"; diff --git a/sysand/src/commands/sync.rs b/sysand/src/commands/sync.rs index 543badd2..2856ea8c 100644 --- a/sysand/src/commands/sync.rs +++ b/sysand/src/commands/sync.rs @@ -9,7 +9,7 @@ use url::ParseError; use sysand_core::{ auth::HTTPAuthentication, - env::local_directory::{DEFAULT_ENV_NAME, DEFAULT_MANIFEST_NAME, LocalDirectoryEnvironment}, + env::local_directory::{DEFAULT_ENV_NAME, DEFAULT_METADATA_NAME, LocalDirectoryEnvironment}, lock::Lock, project::{ AsSyncProjectTokio, ProjectReadAsync, @@ -75,7 +75,7 @@ pub fn command_sync, Policy: HTTPAuthentication>( project_root .as_ref() .join(DEFAULT_ENV_NAME) - .join(DEFAULT_MANIFEST_NAME), + .join(DEFAULT_METADATA_NAME), manifest.to_string(), )?; } diff --git a/sysand/tests/cli_sync.rs b/sysand/tests/cli_sync.rs index d9ad6fe6..5c2e9943 100644 --- a/sysand/tests/cli_sync.rs +++ b/sysand/tests/cli_sync.rs @@ -8,7 +8,7 @@ use predicates::prelude::*; use reqwest::header; use sysand_core::{ commands::lock::DEFAULT_LOCKFILE_NAME, - env::local_directory::{DEFAULT_ENV_NAME, DEFAULT_MANIFEST_NAME, ENTRIES_PATH}, + env::local_directory::{DEFAULT_ENV_NAME, DEFAULT_METADATA_NAME, ENTRIES_PATH}, }; // pub due to https://github.com/rust-lang/rust/issues/46379 @@ -39,17 +39,19 @@ fn sync_to_current() -> Result<(), Box> { let env_path = cwd.join(DEFAULT_ENV_NAME); - let manifest = std::fs::read_to_string(env_path.join(DEFAULT_MANIFEST_NAME))?; + let manifest = std::fs::read_to_string(env_path.join(DEFAULT_METADATA_NAME))?; assert_eq!( manifest, format!( r#"[[project]] name = "sync_to_current" +version = "1.2.3" files = [ "{}/test.sysml", ] usages = [] +editable = true "#, cwd ) @@ -59,9 +61,9 @@ usages = [] assert_eq!(entries.len(), 2); - assert_eq!(entries[0].file_name(), DEFAULT_MANIFEST_NAME); + assert_eq!(entries[0].file_name(), ENTRIES_PATH); - assert_eq!(entries[1].file_name(), ENTRIES_PATH); + assert_eq!(entries[1].file_name(), DEFAULT_METADATA_NAME); Ok(()) } @@ -116,13 +118,15 @@ sources = [ .stderr(predicate::str::contains("Syncing")) .stderr(predicate::str::contains("Installing")); - let manifest = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(DEFAULT_MANIFEST_NAME))?; + let manifest = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(DEFAULT_METADATA_NAME))?; assert_eq!( manifest, format!( r#"[[project]] name = "sync_to_local" +iri = "urn:kpar:sync_to_local" +version = "1.2.3" directory = "{}/{DEFAULT_ENV_NAME}/5ddc0a2e8aaa88ac2bfc71aa0a8d08e020bceac4a90a4b72d8fb7f97ec5bfcc5/1.2.3.kpar" usages = [] "#, @@ -198,13 +202,15 @@ sources = [ info_mock.assert(); meta_mock.assert(); - let manifest = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(DEFAULT_MANIFEST_NAME))?; + let manifest = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(DEFAULT_METADATA_NAME))?; assert_eq!( manifest, format!( r#"[[project]] name = "sync_to_remote" +iri = "urn:kpar:sync_to_remote" +version = "1.2.3" directory = "{}/{DEFAULT_ENV_NAME}/2b95cb7c6d6c08695b0e7c4b7e9d836c21de37fb9c72b0cfa26f53fd84a1b459/1.2.3.kpar" usages = [] "#, From e610490385553abeb6dd7c6e56ee05185f741614 Mon Sep 17 00:00:00 2001 From: "victor.linroth.sensmetry" Date: Thu, 19 Mar 2026 10:43:19 +0100 Subject: [PATCH 06/14] Update metadata format. Signed-off-by: victor.linroth.sensmetry --- core/src/env/local_directory/metadata.rs | 298 +++++++++++++++++------ core/src/env/local_directory/mod.rs | 7 +- sysand/src/commands/sync.rs | 12 +- sysand/tests/cli_sync.rs | 58 +++-- 4 files changed, 270 insertions(+), 105 deletions(-) diff --git a/core/src/env/local_directory/metadata.rs b/core/src/env/local_directory/metadata.rs index 35e72783..0b921003 100644 --- a/core/src/env/local_directory/metadata.rs +++ b/core/src/env/local_directory/metadata.rs @@ -1,9 +1,10 @@ // SPDX-FileCopyrightText: © 2025 Sysand contributors // SPDX-License-Identifier: MIT OR Apache-2.0 -use std::{collections::HashMap, fmt::Display, num::TryFromIntError}; +use std::{fmt::Display, num::TryFromIntError, str::FromStr}; use camino::{Utf8Path, Utf8PathBuf}; +use serde::Deserialize; use thiserror::Error; use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, Value, value}; @@ -12,13 +13,17 @@ use crate::{ env::local_directory::{LocalDirectoryEnvironment, LocalReadError}, lock::{Lock, ResolutionError, Source, multiline_array}, project::{ - local_src::LocalSrcProject, - utils::{FsIoError, wrapfs}, + local_src::{LocalSrcError, LocalSrcProject}, + utils::{FsIoError, relativize_path, wrapfs}, }, }; +pub const METADATA_PREFIX: &str = "# This file is automatically generated by Sysand and is not intended to be edited manually.\n\n"; +pub const CURRENT_METADATA_VERSION: &str = "0.1"; +pub const SUPPORTED_METADATA_VERSIONS: &[&str] = &[CURRENT_METADATA_VERSION]; + #[derive(Debug, Error)] -pub enum ResolvedManifestError { +pub enum LockToEnvMetadataError { #[error(transparent)] ResolutionError(#[from] ResolutionError), #[error("too many dependencies, unable to convert to i64: {0}")] @@ -30,89 +35,129 @@ pub enum ResolvedManifestError { } impl Lock { - pub fn to_resolved_manifest>( + pub fn to_env_metadata>( &self, env: &LocalDirectoryEnvironment, root_path: P, - ) -> Result { + ) -> Result { let resolved_projects = self.resolve_projects(env)?; - let indices = resolved_projects - .iter() - .map(|(p, _)| p) - .enumerate() - .flat_map(|(num, p)| p.identifiers.iter().map(move |iri| (iri.clone(), num))) - .map(|(iri, num)| i64::try_from(num).map(|num| (iri, num))) - .collect::, _>>() - .map_err(ResolvedManifestError::TooManyDependencies)?; - let indices = HashMap::::from_iter(indices); - - let mut projects = vec![]; + let mut metadata = EnvMetadata::default(); for (project, storage) in resolved_projects { let usages = project .usages .iter() - .filter_map(|usage| indices.get(&usage.resource)) - .copied() + .map(|usage| usage.resource.clone()) .collect(); - let purl = project.get_package_url(); - let publisher = purl - .as_ref() - .and_then(|p| p.namespace().map(|ns| ns.to_owned())); - let name = purl.as_ref().map(|p| p.name().to_owned()).or(project.name); - let iri = project.identifiers.first().cloned(); - let version = project.version; if let Some(storage) = storage { - let directory = storage.root_path().to_owned(); - projects.push(ResolvedProject { - publisher, - name, - iri, - version, - location: ResolvedLocation::Directory(directory), + let path = storage + .root_path() + .strip_prefix(env.root_path()) + .expect("path to project in env does not share a prefix with path to env") + .to_owned(); + metadata.projects.push(EnvProject { + publisher: project.publisher, + name: project.name, + identifiers: project.identifiers, + version: project.version, + path, usages, editable: false, + files: None, }); } else if let [Source::Editable { editable }, ..] = project.sources.as_slice() { - let project_path = root_path.as_ref().join(editable.as_str()); let editable_project = LocalSrcProject { nominal_path: None, - project_path: wrapfs::canonicalize(project_path)?, + project_path: wrapfs::canonicalize(root_path.as_ref().join(editable.as_str()))?, }; let files = do_sources_local_src_project_no_deps(&editable_project, true)? .into_iter() + .map(|path| { + relativize_path(path, root_path.as_ref()) + .expect("cannot relativize path to file in editable project") + }) .collect(); - projects.push(ResolvedProject { - publisher, - name, - iri, - version, - location: ResolvedLocation::Files(files), + metadata.projects.push(EnvProject { + publisher: project.publisher, + name: project.name, + identifiers: project.identifiers, + version: project.version, + path: editable.as_str().into(), usages, editable: true, + files: Some(files), }); } } - Ok(ResolvedManifest { projects }) + Ok(metadata) + } +} + +#[derive(Debug, Deserialize)] +pub struct EnvMetadata { + pub version: String, + pub projects: Vec, +} + +impl Default for EnvMetadata { + fn default() -> Self { + EnvMetadata { + version: CURRENT_METADATA_VERSION.to_string(), + projects: vec![], + } } } -#[derive(Debug)] -pub struct ResolvedManifest { - pub projects: Vec, +#[derive(Debug, Error)] +#[error("env metadata version `{0}` is not supported")] +pub struct UnsupportedVersionError(String); + +#[derive(Debug, Error)] +pub enum ParseError { + #[error("failed to parse env metadata file: {0}")] + Toml(#[from] toml::de::Error), + #[error(transparent)] + Unsupported(#[from] UnsupportedVersionError), } -impl Display for ResolvedManifest { +impl Display for EnvMetadata { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.to_toml()) } } -impl ResolvedManifest { +impl FromStr for EnvMetadata { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + let metadata: EnvMetadata = toml::from_str(s)?; + + if !SUPPORTED_METADATA_VERSIONS.contains(&metadata.version.as_str()) { + return Err(ParseError::Unsupported(UnsupportedVersionError( + metadata.version.clone(), + ))); + } + + Ok(metadata) + } +} + +#[derive(Debug, Error)] +pub enum AddProjectError { + #[error(transparent)] + ProjectRead(#[from] LocalSrcError), + #[error("missing project info at `{0}`")] + MissingInfo(Utf8PathBuf), +} + +impl EnvMetadata { pub fn to_toml(&self) -> DocumentMut { let mut doc = DocumentMut::new(); + doc.decor_mut().set_prefix(METADATA_PREFIX); + doc.insert("version", value(Value::from(&self.version))); + let mut projects = ArrayOfTables::new(); for project in &self.projects { projects.push(project.to_toml()); @@ -121,26 +166,98 @@ impl ResolvedManifest { doc } -} -#[derive(Debug)] -pub enum ResolvedLocation { - Directory(Utf8PathBuf), - Files(Vec), + fn find_project(&self, identifiers: &[String], version: &String) -> Option { + for (index, project) in self.projects.iter().enumerate() { + if &project.version == version + && project + .identifiers + .iter() + .any(|iri| identifiers.contains(iri)) + { + return Some(index); + } + } + None + } + + pub fn add_project(&mut self, project: EnvProject) { + if let Some(found) = self.find_project(&project.identifiers, &project.version) { + self.projects[found].merge(&project); + } else { + self.projects.push(project); + } + } + + /// Add `LocalSrcProject` to env. Must have `nominal_path` set. + pub fn add_local_project( + &mut self, + identifiers: Vec, + project: LocalSrcProject, + editable: bool, + ) -> Result<(), AddProjectError> { + let info = project + .get_info()? + .ok_or(AddProjectError::MissingInfo(project.project_path.clone()))?; + let project = EnvProject { + publisher: info.publisher, + name: Some(info.name), + identifiers, + version: info.version, + path: project + .nominal_path + .expect("expected nominal path for project"), + usages: info.usage.into_iter().map(|u| u.resource).collect(), + editable, + files: None, + }; + self.add_project(project); + + Ok(()) + } + + pub fn merge(&mut self, other: EnvMetadata) { + for project in other.projects { + self.add_project(project) + } + } } -#[derive(Debug)] -pub struct ResolvedProject { +/// Metadata describing a project belonging to an environment. +#[derive(Debug, Deserialize)] +pub struct EnvProject { + /// Publisher of the project. Intended for display purposes. pub publisher: Option, + /// Name of the project. Intended for display purposes. pub name: Option, - pub iri: Option, + /// List of identifiers (IRIs) used for the project. + /// The first identifier is to. be considered the canonical + /// identifier, and if the project is not `editable` this + /// is the IRI it is installed as. The rest are considered + /// as aliases. Can only be empty for `editable` projects. + pub identifiers: Vec, + /// Version of the project. pub version: String, - pub location: ResolvedLocation, - pub usages: Vec, + /// Path to the root directory of the project. + /// If the project is not `editable` this should be relative + /// to the env directory and otherwise it should be relative + /// to the workspace root. + pub path: Utf8PathBuf, + /// Usages of the project. Intended for tools needing to + /// track the interdependence of project in the environment. + pub usages: Vec, + /// Indicator of wether the project is fully installed in + /// the environment or located elsewhere. pub editable: bool, + /// In case of an `editable` project these are the files + /// belonging to the project. Intended for tools that + /// are not able to natively parse and understand the + /// projects `.meta.json` file. Paths should be relative + /// to the `path` of the project. + pub files: Option>, } -impl ResolvedProject { +impl EnvProject { pub fn to_toml(&self) -> Table { let mut table = Table::new(); if let Some(publisher) = &self.publisher { @@ -149,29 +266,62 @@ impl ResolvedProject { if let Some(name) = &self.name { table.insert("name", value(name)); } - if let Some(iri) = &self.iri { - table.insert("iri", value(iri)); + if !self.identifiers.is_empty() { + table.insert( + "identifiers", + value(multiline_array(self.identifiers.iter())), + ); } table.insert("version", value(&self.version)); - match &self.location { - ResolvedLocation::Directory(dir) => { - table.insert("directory", value(dir.as_str())); - } - ResolvedLocation::Files(files) => { - let file_iter = files.iter().map(|f| Value::from(f.as_str())); - if !files.is_empty() { - table.insert("files", value(multiline_array(file_iter))); - } else { - table.insert("files", value(Array::from_iter(file_iter))); - } - } + table.insert("path", value(self.path.as_str())); + if !self.usages.is_empty() { + table.insert("usages", value(multiline_array(self.usages.iter()))); } - let usages = Array::from_iter(self.usages.iter().copied().map(Value::from)); - table.insert("usages", value(usages)); if self.editable { table.insert("editable", value(true)); } + if let Some(files) = &self.files { + let file_iter = files.iter().map(|f| Value::from(f.as_str())); + if files.is_empty() { + table.insert("files", value(Array::from_iter(file_iter))); + } else { + table.insert("files", value(multiline_array(file_iter))); + } + } table } + + pub fn merge(&mut self, other: &EnvProject) { + for iri in &other.identifiers { + if !self.identifiers.contains(iri) { + self.identifiers.push(iri.clone()); + } + } + } +} + +#[derive(Error, Debug)] +pub enum EnvMetadataReadError { + #[error("failed to deserialize TOML file `{0}`: {1}")] + Toml(Box, toml::de::Error), + #[error(transparent)] + Io(#[from] Box), + #[error(transparent)] + Unsupported(UnsupportedVersionError), +} + +impl From for EnvMetadataReadError { + fn from(err: FsIoError) -> Self { + Self::Io(Box::new(err)) + } +} + +pub fn load_env_metadata>(path: P) -> Result { + let result = EnvMetadata::from_str(wrapfs::read_to_string(path.as_ref())?.as_str()); + + result.map_err(|parse_err| match parse_err { + ParseError::Toml(err) => EnvMetadataReadError::Toml(path.as_ref().to_owned().into(), err), + ParseError::Unsupported(err) => EnvMetadataReadError::Unsupported(err), + }) } diff --git a/core/src/env/local_directory/mod.rs b/core/src/env/local_directory/mod.rs index 63e81a95..36d3d999 100644 --- a/core/src/env/local_directory/mod.rs +++ b/core/src/env/local_directory/mod.rs @@ -33,8 +33,7 @@ pub struct LocalDirectoryEnvironment { pub const DEFAULT_ENV_NAME: &str = "sysand_env"; -pub const DEFAULT_METADATA_NAME: &str = "env.toml"; - +pub const METADATA_PATH: &str = "env.toml"; pub const ENTRIES_PATH: &str = "entries.txt"; pub const VERSIONS_PATH: &str = "versions.txt"; @@ -43,6 +42,10 @@ impl LocalDirectoryEnvironment { &self.environment_path } + pub fn metadata_path(&self) -> Utf8PathBuf { + self.environment_path.join(METADATA_PATH) + } + pub fn entries_path(&self) -> Utf8PathBuf { self.environment_path.join(ENTRIES_PATH) } diff --git a/sysand/src/commands/sync.rs b/sysand/src/commands/sync.rs index 2856ea8c..b802a9bd 100644 --- a/sysand/src/commands/sync.rs +++ b/sysand/src/commands/sync.rs @@ -9,7 +9,7 @@ use url::ParseError; use sysand_core::{ auth::HTTPAuthentication, - env::local_directory::{DEFAULT_ENV_NAME, DEFAULT_METADATA_NAME, LocalDirectoryEnvironment}, + env::local_directory::{DEFAULT_ENV_NAME, LocalDirectoryEnvironment, METADATA_PATH}, lock::Lock, project::{ AsSyncProjectTokio, ProjectReadAsync, @@ -27,7 +27,7 @@ use sysand_core::{ pub fn command_sync, Policy: HTTPAuthentication>( lock: &Lock, project_root: P, - update_manifest: bool, + update_metadata: bool, env: &mut LocalDirectoryEnvironment, client: reqwest_middleware::ClientWithMiddleware, provided_iris: &HashMap>, @@ -69,14 +69,14 @@ pub fn command_sync, Policy: HTTPAuthentication>( provided_iris, )?; - if update_manifest { - let manifest = lock.to_resolved_manifest(env, &project_root)?; + if update_metadata { + let env_metadata = lock.to_env_metadata(env, &project_root)?; wrapfs::write( project_root .as_ref() .join(DEFAULT_ENV_NAME) - .join(DEFAULT_METADATA_NAME), - manifest.to_string(), + .join(METADATA_PATH), + env_metadata.to_string(), )?; } diff --git a/sysand/tests/cli_sync.rs b/sysand/tests/cli_sync.rs index 5c2e9943..ca0a1d81 100644 --- a/sysand/tests/cli_sync.rs +++ b/sysand/tests/cli_sync.rs @@ -8,7 +8,7 @@ use predicates::prelude::*; use reqwest::header; use sysand_core::{ commands::lock::DEFAULT_LOCKFILE_NAME, - env::local_directory::{DEFAULT_ENV_NAME, DEFAULT_METADATA_NAME, ENTRIES_PATH}, + env::local_directory::{DEFAULT_ENV_NAME, ENTRIES_PATH, METADATA_PATH}, }; // pub due to https://github.com/rust-lang/rust/issues/46379 @@ -39,21 +39,25 @@ fn sync_to_current() -> Result<(), Box> { let env_path = cwd.join(DEFAULT_ENV_NAME); - let manifest = std::fs::read_to_string(env_path.join(DEFAULT_METADATA_NAME))?; + let manifest = std::fs::read_to_string(env_path.join(METADATA_PATH))?; assert_eq!( manifest, format!( - r#"[[project]] + r#"# This file is automatically generated by Sysand and is not intended to be edited manually. + +version = "0.1" + +[[project]] +publisher = "untitled" name = "sync_to_current" version = "1.2.3" +path = "." +editable = true files = [ - "{}/test.sysml", + "test.sysml", ] -usages = [] -editable = true -"#, - cwd +"# ) ); @@ -63,7 +67,7 @@ editable = true assert_eq!(entries[0].file_name(), ENTRIES_PATH); - assert_eq!(entries[1].file_name(), DEFAULT_METADATA_NAME); + assert_eq!(entries[1].file_name(), METADATA_PATH); Ok(()) } @@ -118,19 +122,23 @@ sources = [ .stderr(predicate::str::contains("Syncing")) .stderr(predicate::str::contains("Installing")); - let manifest = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(DEFAULT_METADATA_NAME))?; + let manifest = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH))?; assert_eq!( manifest, format!( - r#"[[project]] + r#"# This file is automatically generated by Sysand and is not intended to be edited manually. + +version = "0.1" + +[[project]] name = "sync_to_local" -iri = "urn:kpar:sync_to_local" +identifiers = [ + "urn:kpar:sync_to_local", +] version = "1.2.3" -directory = "{}/{DEFAULT_ENV_NAME}/5ddc0a2e8aaa88ac2bfc71aa0a8d08e020bceac4a90a4b72d8fb7f97ec5bfcc5/1.2.3.kpar" -usages = [] -"#, - cwd +path = "5ddc0a2e8aaa88ac2bfc71aa0a8d08e020bceac4a90a4b72d8fb7f97ec5bfcc5/1.2.3.kpar" +"# ) ); @@ -202,19 +210,23 @@ sources = [ info_mock.assert(); meta_mock.assert(); - let manifest = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(DEFAULT_METADATA_NAME))?; + let manifest = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH))?; assert_eq!( manifest, format!( - r#"[[project]] + r#"# This file is automatically generated by Sysand and is not intended to be edited manually. + +version = "0.1" + +[[project]] name = "sync_to_remote" -iri = "urn:kpar:sync_to_remote" +identifiers = [ + "urn:kpar:sync_to_remote", +] version = "1.2.3" -directory = "{}/{DEFAULT_ENV_NAME}/2b95cb7c6d6c08695b0e7c4b7e9d836c21de37fb9c72b0cfa26f53fd84a1b459/1.2.3.kpar" -usages = [] -"#, - cwd +path = "2b95cb7c6d6c08695b0e7c4b7e9d836c21de37fb9c72b0cfa26f53fd84a1b459/1.2.3.kpar" +"# ) ); From eb98d84a19be60d83f0efef6b8bcc6f2bb7d6dea Mon Sep 17 00:00:00 2001 From: "victor.linroth.sensmetry" Date: Fri, 20 Mar 2026 08:43:01 +0100 Subject: [PATCH 07/14] Make sure metadata is updated along with env. Signed-off-by: victor.linroth.sensmetry --- core/src/commands/sources.rs | 6 +-- core/src/env/local_directory/metadata.rs | 37 +++++++++++----- core/src/env/local_directory/utils.rs | 2 +- sysand/src/commands/add.rs | 1 - sysand/src/commands/clone.rs | 1 - sysand/src/commands/env.rs | 56 +++++++++++++++++------- sysand/src/commands/sync.rs | 23 +++++----- sysand/src/lib.rs | 5 +-- sysand/tests/cli_env.rs | 8 +++- sysand/tests/cli_sync.rs | 20 ++++----- 10 files changed, 100 insertions(+), 59 deletions(-) diff --git a/core/src/commands/sources.rs b/core/src/commands/sources.rs index 361736ae..51758de1 100644 --- a/core/src/commands/sources.rs +++ b/core/src/commands/sources.rs @@ -79,14 +79,14 @@ pub fn do_sources_local_src_project_no_deps( project: &LocalSrcProject, include_index: bool, ) -> Result, LocalSourcesError> { - let unix_srcs = do_sources_project_no_deps(project, include_index)?; + let unix_sources = do_sources_project_no_deps(project, include_index)?; - let srcs: Result, _> = unix_srcs + let sources: Result, _> = unix_sources .iter() .map(|path| project.get_source_path(path)) .collect(); - Ok(srcs?) + Ok(sources?) } /// Transitively resolve a list of usages (typically the usages of some project) diff --git a/core/src/env/local_directory/metadata.rs b/core/src/env/local_directory/metadata.rs index 0b921003..a44e64b7 100644 --- a/core/src/env/local_directory/metadata.rs +++ b/core/src/env/local_directory/metadata.rs @@ -59,9 +59,9 @@ impl Lock { metadata.projects.push(EnvProject { publisher: project.publisher, name: project.name, - identifiers: project.identifiers, version: project.version, path, + identifiers: project.identifiers, usages, editable: false, files: None, @@ -81,9 +81,9 @@ impl Lock { metadata.projects.push(EnvProject { publisher: project.publisher, name: project.name, - identifiers: project.identifiers, version: project.version, path: editable.as_str().into(), + identifiers: project.identifiers, usages, editable: true, files: Some(files), @@ -98,6 +98,7 @@ impl Lock { #[derive(Debug, Deserialize)] pub struct EnvMetadata { pub version: String, + #[serde(rename = "project", skip_serializing_if = "Vec::is_empty", default)] pub projects: Vec, } @@ -189,6 +190,17 @@ impl EnvMetadata { } } + pub fn remove_project, V: AsRef>(&mut self, iri: S, version: Option) { + if let Some(v) = version { + self.projects.retain(|p| { + p.version != v.as_ref() || !p.identifiers.iter().any(|i| i == iri.as_ref()) + }); + } else { + self.projects + .retain(|p| !p.identifiers.iter().any(|i| i == iri.as_ref())); + } + } + /// Add `LocalSrcProject` to env. Must have `nominal_path` set. pub fn add_local_project( &mut self, @@ -202,11 +214,11 @@ impl EnvMetadata { let project = EnvProject { publisher: info.publisher, name: Some(info.name), - identifiers, version: info.version, path: project .nominal_path .expect("expected nominal path for project"), + identifiers, usages: info.usage.into_iter().map(|u| u.resource).collect(), editable, files: None, @@ -230,12 +242,6 @@ pub struct EnvProject { pub publisher: Option, /// Name of the project. Intended for display purposes. pub name: Option, - /// List of identifiers (IRIs) used for the project. - /// The first identifier is to. be considered the canonical - /// identifier, and if the project is not `editable` this - /// is the IRI it is installed as. The rest are considered - /// as aliases. Can only be empty for `editable` projects. - pub identifiers: Vec, /// Version of the project. pub version: String, /// Path to the root directory of the project. @@ -243,11 +249,20 @@ pub struct EnvProject { /// to the env directory and otherwise it should be relative /// to the workspace root. pub path: Utf8PathBuf, + /// List of identifiers (IRIs) used for the project. + /// The first identifier is to. be considered the canonical + /// identifier, and if the project is not `editable` this + /// is the IRI it is installed as. The rest are considered + /// as aliases. Can only be empty for `editable` projects. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub identifiers: Vec, /// Usages of the project. Intended for tools needing to /// track the interdependence of project in the environment. + #[serde(skip_serializing_if = "Vec::is_empty", default)] pub usages: Vec, /// Indicator of wether the project is fully installed in /// the environment or located elsewhere. + #[serde(skip_serializing_if = "bool::is_false", default)] pub editable: bool, /// In case of an `editable` project these are the files /// belonging to the project. Intended for tools that @@ -266,14 +281,14 @@ impl EnvProject { if let Some(name) = &self.name { table.insert("name", value(name)); } + table.insert("version", value(&self.version)); + table.insert("path", value(self.path.as_str())); if !self.identifiers.is_empty() { table.insert( "identifiers", value(multiline_array(self.identifiers.iter())), ); } - table.insert("version", value(&self.version)); - table.insert("path", value(self.path.as_str())); if !self.usages.is_empty() { table.insert("usages", value(multiline_array(self.usages.iter()))); } diff --git a/core/src/env/local_directory/utils.rs b/core/src/env/local_directory/utils.rs index cce7b104..e984a9cb 100644 --- a/core/src/env/local_directory/utils.rs +++ b/core/src/env/local_directory/utils.rs @@ -80,7 +80,7 @@ pub fn try_remove_files, I: Iterator>( moved.push(path.to_path_buf()); } Err(cause) => { - // NOTE: This dance is to bypass the fact that std::io::error is not Clone-eable... + // NOTE: This dance is to bypass the fact that std::io::error is not cloneable... let mut catastrophic_error = None; for (j, recover) in moved.iter().enumerate() { if let Err(err) = move_fs_item(tempdir.path().join(j.to_string()), recover) { diff --git a/sysand/src/commands/add.rs b/sysand/src/commands/add.rs index f0f29de0..da4d3eea 100644 --- a/sysand/src/commands/add.rs +++ b/sysand/src/commands/add.rs @@ -275,7 +275,6 @@ fn resolve_deps, Policy: HTTPAuthentication>( command_sync( &lock, project_root, - true, &mut env, client, &provided_iris, diff --git a/sysand/src/commands/clone.rs b/sysand/src/commands/clone.rs index 566d5902..9db2229a 100644 --- a/sysand/src/commands/clone.rs +++ b/sysand/src/commands/clone.rs @@ -142,7 +142,6 @@ pub fn command_clone( command_sync( &lock, &project.inner().project_path, - true, &mut env, client, &provided_iris, diff --git a/sysand/src/commands/env.rs b/sysand/src/commands/env.rs index c466ec2c..2f883935 100644 --- a/sysand/src/commands/env.rs +++ b/sysand/src/commands/env.rs @@ -13,7 +13,7 @@ use sysand_core::{ commands::{env::do_env_local_dir, lock::LockOutcome}, config::Config, context::ProjectContext, - env::local_directory::LocalDirectoryEnvironment, + env::local_directory::{LocalDirectoryEnvironment, metadata::load_env_metadata}, lock::Lock, model::InterchangeProjectUsage, project::{ @@ -120,7 +120,7 @@ pub fn command_env_install( // TODO: don't use different root project resolution // mechanisms depending on no_deps if no_deps { - let (_version, storage) = + let (version, storage) = crate::commands::clone::get_project_version(&iri, version, &resolver)?; sysand_core::commands::env::do_env_install_project( &iri, @@ -129,6 +129,7 @@ pub fn command_env_install( allow_overwrite, allow_multiple, )?; + add_single_env_project(iri, version.to_string(), env)?; } else { let usages = vec![InterchangeProjectUsage { resource: fluent_uri::Iri::from_str(iri.as_ref())?, @@ -159,7 +160,6 @@ pub fn command_env_install( command_sync( &lock, project_root, - false, &mut env, client, &provided_iris, @@ -231,14 +231,14 @@ pub fn command_env_install_path( Some(config.index_urls(index, vec![DEFAULT_INDEX_URL.to_string()], default_index)?) }; - if let Some(version) = version { - let project_version = project - .get_info()? - .ok_or_else(|| anyhow!("missing project info"))? - .version; - if version != project_version { - bail!("given version {version} does not match project version {project_version}") - } + let project_version = project + .get_info()? + .ok_or_else(|| anyhow!("missing project info"))? + .version; + if let Some(version) = version + && version != project_version + { + bail!("given version {version} does not match project version {project_version}") } // TODO: Fix this hack. Currently installing manually then turning project into Editable to @@ -296,24 +296,50 @@ pub fn command_env_install_path( command_sync( &lock, project_root, - false, &mut env, client, &provided_iris, runtime, auth_policy, )?; + } else { + add_single_env_project(iri, project_version, env)?; } Ok(()) } -pub fn command_env_uninstall, Q: AsRef>( +fn add_single_env_project, V: AsRef>( iri: S, - version: Option, + version: V, env: LocalDirectoryEnvironment, ) -> Result<()> { - sysand_core::commands::env::do_env_uninstall(iri, version, env)?; + let metadata_path = env.metadata_path(); + let mut env_metadata = load_env_metadata(&metadata_path)?; + let project_path = env.project_path(&iri, version); + let project = LocalSrcProject { + nominal_path: Some(project_path.strip_prefix(env.root_path())?.to_owned()), + project_path, + }; + env_metadata.add_local_project(vec![iri.as_ref().to_owned()], project, false)?; + wrapfs::write(metadata_path, env_metadata.to_string())?; + + Ok(()) +} + +pub fn command_env_uninstall, V: AsRef>( + iri: S, + version: Option, + env: LocalDirectoryEnvironment, +) -> Result<()> { + let metadata_path = env.metadata_path(); + + sysand_core::commands::env::do_env_uninstall(&iri, version.as_ref(), env)?; + + let mut env_metadata = load_env_metadata(&metadata_path)?; + env_metadata.remove_project(iri, version); + wrapfs::write(metadata_path, env_metadata.to_string())?; + Ok(()) } diff --git a/sysand/src/commands/sync.rs b/sysand/src/commands/sync.rs index b802a9bd..9fd7f2f4 100644 --- a/sysand/src/commands/sync.rs +++ b/sysand/src/commands/sync.rs @@ -9,7 +9,7 @@ use url::ParseError; use sysand_core::{ auth::HTTPAuthentication, - env::local_directory::{DEFAULT_ENV_NAME, LocalDirectoryEnvironment, METADATA_PATH}, + env::local_directory::{LocalDirectoryEnvironment, metadata::load_env_metadata}, lock::Lock, project::{ AsSyncProjectTokio, ProjectReadAsync, @@ -27,7 +27,6 @@ use sysand_core::{ pub fn command_sync, Policy: HTTPAuthentication>( lock: &Lock, project_root: P, - update_metadata: bool, env: &mut LocalDirectoryEnvironment, client: reqwest_middleware::ClientWithMiddleware, provided_iris: &HashMap>, @@ -69,16 +68,16 @@ pub fn command_sync, Policy: HTTPAuthentication>( provided_iris, )?; - if update_metadata { - let env_metadata = lock.to_env_metadata(env, &project_root)?; - wrapfs::write( - project_root - .as_ref() - .join(DEFAULT_ENV_NAME) - .join(METADATA_PATH), - env_metadata.to_string(), - )?; - } + let lock_metadata = lock.to_env_metadata(env, project_root)?; + let env_metadata = if wrapfs::is_file(env.metadata_path())? { + let mut env_metadata = load_env_metadata(env.metadata_path())?; + env_metadata.merge(lock_metadata); + env_metadata + } else { + lock_metadata + }; + + wrapfs::write(env.metadata_path(), env_metadata.to_string())?; Ok(()) } diff --git a/sysand/src/lib.rs b/sysand/src/lib.rs index 3e9577e3..21ad9800 100644 --- a/sysand/src/lib.rs +++ b/sysand/src/lib.rs @@ -110,7 +110,7 @@ where fn set_panic_hook() { // TODO: use `panic::update_hook()` once it's stable - // also set bactrace style once it's stable, but take + // also set backtrace style once it's stable, but take // into account the current level let default_hook = panic::take_hook(); // panic::set_backtrace_style(panic::BacktraceStyle::Short); @@ -182,7 +182,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { .unwrap(), ); - let _runtime_keepalive = runtime.clone(); + let _runtime_keep_alive = runtime.clone(); // FIXME: This is a temporary implementation to provide credentials until // https://github.com/sensmetry/sysand/pull/157 @@ -419,7 +419,6 @@ pub fn run_cli(args: cli::Args) -> Result<()> { command_sync( &lock, project_root, - true, &mut local_environment, client, &provided_iris, diff --git a/sysand/tests/cli_env.rs b/sysand/tests/cli_env.rs index 3e288638..4bad4403 100644 --- a/sysand/tests/cli_env.rs +++ b/sysand/tests/cli_env.rs @@ -7,7 +7,9 @@ use assert_cmd::prelude::*; use camino::Utf8Path; use mockito::Server; use predicates::prelude::*; -use sysand_core::env::local_directory::{DEFAULT_ENV_NAME, ENTRIES_PATH, VERSIONS_PATH}; +use sysand_core::env::local_directory::{ + DEFAULT_ENV_NAME, ENTRIES_PATH, METADATA_PATH, VERSIONS_PATH, +}; // pub due to https://github.com/rust-lang/rust/issues/46379 mod common; @@ -124,10 +126,12 @@ fn env_install_from_local_dir() -> Result<(), Box> { let entries = std::fs::read_dir(cwd.join(env_path))?.collect::, _>>()?; - assert_eq!(entries.len(), 1); + assert_eq!(entries.len(), 2); assert_eq!(entries[0].file_name(), ENTRIES_PATH); + assert_eq!(entries[1].file_name(), METADATA_PATH); + assert_eq!(std::fs::read_to_string(entries[0].path())?, ""); Ok(()) diff --git a/sysand/tests/cli_sync.rs b/sysand/tests/cli_sync.rs index ca0a1d81..705c8a46 100644 --- a/sysand/tests/cli_sync.rs +++ b/sysand/tests/cli_sync.rs @@ -39,10 +39,10 @@ fn sync_to_current() -> Result<(), Box> { let env_path = cwd.join(DEFAULT_ENV_NAME); - let manifest = std::fs::read_to_string(env_path.join(METADATA_PATH))?; + let env_metadata = std::fs::read_to_string(env_path.join(METADATA_PATH))?; assert_eq!( - manifest, + env_metadata, format!( r#"# This file is automatically generated by Sysand and is not intended to be edited manually. @@ -122,10 +122,10 @@ sources = [ .stderr(predicate::str::contains("Syncing")) .stderr(predicate::str::contains("Installing")); - let manifest = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH))?; + let env_metadata = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH))?; assert_eq!( - manifest, + env_metadata, format!( r#"# This file is automatically generated by Sysand and is not intended to be edited manually. @@ -133,11 +133,11 @@ version = "0.1" [[project]] name = "sync_to_local" +version = "1.2.3" +path = "5ddc0a2e8aaa88ac2bfc71aa0a8d08e020bceac4a90a4b72d8fb7f97ec5bfcc5/1.2.3.kpar" identifiers = [ "urn:kpar:sync_to_local", ] -version = "1.2.3" -path = "5ddc0a2e8aaa88ac2bfc71aa0a8d08e020bceac4a90a4b72d8fb7f97ec5bfcc5/1.2.3.kpar" "# ) ); @@ -210,10 +210,10 @@ sources = [ info_mock.assert(); meta_mock.assert(); - let manifest = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH))?; + let env_metadata = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH))?; assert_eq!( - manifest, + env_metadata, format!( r#"# This file is automatically generated by Sysand and is not intended to be edited manually. @@ -221,11 +221,11 @@ version = "0.1" [[project]] name = "sync_to_remote" +version = "1.2.3" +path = "2b95cb7c6d6c08695b0e7c4b7e9d836c21de37fb9c72b0cfa26f53fd84a1b459/1.2.3.kpar" identifiers = [ "urn:kpar:sync_to_remote", ] -version = "1.2.3" -path = "2b95cb7c6d6c08695b0e7c4b7e9d836c21de37fb9c72b0cfa26f53fd84a1b459/1.2.3.kpar" "# ) ); From ca5781fda1b89042310901eb9e4131e7c95dcc21 Mon Sep 17 00:00:00 2001 From: "victor.linroth.sensmetry" Date: Fri, 20 Mar 2026 09:59:45 +0100 Subject: [PATCH 08/14] Fix for tests on Ubuntu (ARM). Signed-off-by: victor.linroth.sensmetry --- sysand/tests/cli_env.rs | 16 ++++++++++++---- sysand/tests/cli_sync.rs | 11 +++++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/sysand/tests/cli_env.rs b/sysand/tests/cli_env.rs index 4bad4403..0c8cbad8 100644 --- a/sysand/tests/cli_env.rs +++ b/sysand/tests/cli_env.rs @@ -126,13 +126,21 @@ fn env_install_from_local_dir() -> Result<(), Box> { let entries = std::fs::read_dir(cwd.join(env_path))?.collect::, _>>()?; - assert_eq!(entries.len(), 2); + let mut entry_names: Vec<_> = entries + .iter() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); - assert_eq!(entries[0].file_name(), ENTRIES_PATH); + let entries_path_index = entry_names.iter().position(|e| e == ENTRIES_PATH).unwrap(); - assert_eq!(entries[1].file_name(), METADATA_PATH); + entry_names.sort(); - assert_eq!(std::fs::read_to_string(entries[0].path())?, ""); + assert_eq!(entry_names, [ENTRIES_PATH, METADATA_PATH]); + + assert_eq!( + std::fs::read_to_string(entries[entries_path_index].path())?, + "" + ); Ok(()) } diff --git a/sysand/tests/cli_sync.rs b/sysand/tests/cli_sync.rs index 705c8a46..e63c00ba 100644 --- a/sysand/tests/cli_sync.rs +++ b/sysand/tests/cli_sync.rs @@ -61,13 +61,16 @@ files = [ ) ); - let entries = std::fs::read_dir(env_path)?.collect::, _>>()?; + let entries: Result, _> = std::fs::read_dir(env_path)?.collect(); - assert_eq!(entries.len(), 2); + let mut entry_names: Vec<_> = entries? + .iter() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); - assert_eq!(entries[0].file_name(), ENTRIES_PATH); + entry_names.sort(); - assert_eq!(entries[1].file_name(), METADATA_PATH); + assert_eq!(entry_names, [ENTRIES_PATH, METADATA_PATH]); Ok(()) } From 5a54a7029605dab695a3fd23830ac5219343de91 Mon Sep 17 00:00:00 2001 From: "victor.linroth.sensmetry" Date: Mon, 23 Mar 2026 15:01:46 +0100 Subject: [PATCH 09/14] Fix paths issues on Windows. Signed-off-by: victor.linroth.sensmetry --- core/src/env/local_directory/metadata.rs | 37 ++++++++++++++---------- sysand/tests/cli_sync.rs | 19 +++++++++--- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/core/src/env/local_directory/metadata.rs b/core/src/env/local_directory/metadata.rs index a44e64b7..3794764b 100644 --- a/core/src/env/local_directory/metadata.rs +++ b/core/src/env/local_directory/metadata.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: © 2025 Sysand contributors // SPDX-License-Identifier: MIT OR Apache-2.0 -use std::{fmt::Display, num::TryFromIntError, str::FromStr}; +use std::{fmt::Display, str::FromStr}; use camino::{Utf8Path, Utf8PathBuf}; use serde::Deserialize; @@ -26,8 +26,6 @@ pub const SUPPORTED_METADATA_VERSIONS: &[&str] = &[CURRENT_METADATA_VERSION]; pub enum LockToEnvMetadataError { #[error(transparent)] ResolutionError(#[from] ResolutionError), - #[error("too many dependencies, unable to convert to i64: {0}")] - TooManyDependencies(TryFromIntError), #[error(transparent)] LocalSources(#[from] LocalSourcesError), #[error(transparent)] @@ -51,9 +49,10 @@ impl Lock { .collect(); if let Some(storage) = storage { - let path = storage - .root_path() - .strip_prefix(env.root_path()) + let project_path = wrapfs::canonicalize(storage.root_path())?; + let env_path = wrapfs::canonicalize(env.root_path())?; + let path = project_path + .strip_prefix(env_path) .expect("path to project in env does not share a prefix with path to env") .to_owned(); metadata.projects.push(EnvProject { @@ -67,14 +66,15 @@ impl Lock { files: None, }); } else if let [Source::Editable { editable }, ..] = project.sources.as_slice() { + let root_path = wrapfs::canonicalize(root_path.as_ref())?; let editable_project = LocalSrcProject { nominal_path: None, - project_path: wrapfs::canonicalize(root_path.as_ref().join(editable.as_str()))?, + project_path: wrapfs::canonicalize(root_path.join(editable.as_str()))?, }; let files = do_sources_local_src_project_no_deps(&editable_project, true)? .into_iter() .map(|path| { - relativize_path(path, root_path.as_ref()) + relativize_path(path, root_path.clone()) .expect("cannot relativize path to file in editable project") }) .collect(); @@ -272,6 +272,17 @@ pub struct EnvProject { pub files: Option>, } +// `toml_edit` normally serializes string differently depending +// on if they contain any backslashes or not. In order to have +// consistent string types for paths on different platforms we +// use this function to force all paths to serialize to string +// literals. +fn from_path>(path: P) -> Value { + format!(r#"'{}'"#, path.as_ref().as_str()) + .parse::() + .unwrap() +} + impl EnvProject { pub fn to_toml(&self) -> Table { let mut table = Table::new(); @@ -282,7 +293,7 @@ impl EnvProject { table.insert("name", value(name)); } table.insert("version", value(&self.version)); - table.insert("path", value(self.path.as_str())); + table.insert("path", value(from_path(&self.path))); if !self.identifiers.is_empty() { table.insert( "identifiers", @@ -296,7 +307,7 @@ impl EnvProject { table.insert("editable", value(true)); } if let Some(files) = &self.files { - let file_iter = files.iter().map(|f| Value::from(f.as_str())); + let file_iter = files.iter().map(from_path); if files.is_empty() { table.insert("files", value(Array::from_iter(file_iter))); } else { @@ -326,12 +337,6 @@ pub enum EnvMetadataReadError { Unsupported(UnsupportedVersionError), } -impl From for EnvMetadataReadError { - fn from(err: FsIoError) -> Self { - Self::Io(Box::new(err)) - } -} - pub fn load_env_metadata>(path: P) -> Result { let result = EnvMetadata::from_str(wrapfs::read_to_string(path.as_ref())?.as_str()); diff --git a/sysand/tests/cli_sync.rs b/sysand/tests/cli_sync.rs index e63c00ba..ee14f82b 100644 --- a/sysand/tests/cli_sync.rs +++ b/sysand/tests/cli_sync.rs @@ -1,7 +1,10 @@ // SPDX-FileCopyrightText: © 2025 Sysand contributors // SPDX-License-Identifier: MIT OR Apache-2.0 +use std::str::FromStr; + use assert_cmd::prelude::*; +use camino::Utf8PathBuf; use indexmap::IndexMap; use mockito::Matcher; use predicates::prelude::*; @@ -52,10 +55,10 @@ version = "0.1" publisher = "untitled" name = "sync_to_current" version = "1.2.3" -path = "." +path = '.' editable = true files = [ - "test.sysml", + 'test.sysml', ] "# ) @@ -126,6 +129,10 @@ sources = [ .stderr(predicate::str::contains("Installing")); let env_metadata = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH))?; + let project_env_path = + Utf8PathBuf::from_str("5ddc0a2e8aaa88ac2bfc71aa0a8d08e020bceac4a90a4b72d8fb7f97ec5bfcc5") + .unwrap() + .join("1.2.3.kpar"); assert_eq!( env_metadata, @@ -137,7 +144,7 @@ version = "0.1" [[project]] name = "sync_to_local" version = "1.2.3" -path = "5ddc0a2e8aaa88ac2bfc71aa0a8d08e020bceac4a90a4b72d8fb7f97ec5bfcc5/1.2.3.kpar" +path = '{project_env_path}' identifiers = [ "urn:kpar:sync_to_local", ] @@ -214,6 +221,10 @@ sources = [ meta_mock.assert(); let env_metadata = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH))?; + let project_env_path = + Utf8PathBuf::from_str("2b95cb7c6d6c08695b0e7c4b7e9d836c21de37fb9c72b0cfa26f53fd84a1b459") + .unwrap() + .join("1.2.3.kpar"); assert_eq!( env_metadata, @@ -225,7 +236,7 @@ version = "0.1" [[project]] name = "sync_to_remote" version = "1.2.3" -path = "2b95cb7c6d6c08695b0e7c4b7e9d836c21de37fb9c72b0cfa26f53fd84a1b459/1.2.3.kpar" +path = '{project_env_path}' identifiers = [ "urn:kpar:sync_to_remote", ] From 0a908e7c2bfeb835fd778dff1d609f014f2310d9 Mon Sep 17 00:00:00 2001 From: "victor.linroth.sensmetry" Date: Tue, 24 Mar 2026 11:54:07 +0100 Subject: [PATCH 10/14] Use Unix paths. Signed-off-by: victor.linroth.sensmetry --- core/src/env/local_directory/metadata.rs | 33 ++++++-------- core/src/lock.rs | 30 ++++-------- core/src/project/local_src.rs | 16 +------ core/src/project/utils.rs | 58 +++++++++++++++++++++++- sysand/tests/cli_sync.rs | 19 ++------ 5 files changed, 87 insertions(+), 69 deletions(-) diff --git a/core/src/env/local_directory/metadata.rs b/core/src/env/local_directory/metadata.rs index 3794764b..242894a0 100644 --- a/core/src/env/local_directory/metadata.rs +++ b/core/src/env/local_directory/metadata.rs @@ -7,6 +7,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use serde::Deserialize; use thiserror::Error; use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, Value, value}; +use typed_path::Utf8UnixPathBuf; use crate::{ commands::sources::{LocalSourcesError, do_sources_local_src_project_no_deps}, @@ -14,7 +15,10 @@ use crate::{ lock::{Lock, ResolutionError, Source, multiline_array}, project::{ local_src::{LocalSrcError, LocalSrcProject}, - utils::{FsIoError, relativize_path, wrapfs}, + utils::{ + FsIoError, ToUnixPathBuf, deserialize_optional_unix_paths, deserialize_unix_path, + relativize_path, wrapfs, + }, }, }; @@ -54,7 +58,7 @@ impl Lock { let path = project_path .strip_prefix(env_path) .expect("path to project in env does not share a prefix with path to env") - .to_owned(); + .to_unix_path_buf(); metadata.projects.push(EnvProject { publisher: project.publisher, name: project.name, @@ -76,6 +80,7 @@ impl Lock { .map(|path| { relativize_path(path, root_path.clone()) .expect("cannot relativize path to file in editable project") + .to_unix_path_buf() }) .collect(); metadata.projects.push(EnvProject { @@ -217,7 +222,8 @@ impl EnvMetadata { version: info.version, path: project .nominal_path - .expect("expected nominal path for project"), + .expect("expected nominal path for project") + .to_unix_path_buf(), identifiers, usages: info.usage.into_iter().map(|u| u.resource).collect(), editable, @@ -248,7 +254,8 @@ pub struct EnvProject { /// If the project is not `editable` this should be relative /// to the env directory and otherwise it should be relative /// to the workspace root. - pub path: Utf8PathBuf, + #[serde(deserialize_with = "deserialize_unix_path")] + pub path: Utf8UnixPathBuf, /// List of identifiers (IRIs) used for the project. /// The first identifier is to. be considered the canonical /// identifier, and if the project is not `editable` this @@ -269,18 +276,8 @@ pub struct EnvProject { /// are not able to natively parse and understand the /// projects `.meta.json` file. Paths should be relative /// to the `path` of the project. - pub files: Option>, -} - -// `toml_edit` normally serializes string differently depending -// on if they contain any backslashes or not. In order to have -// consistent string types for paths on different platforms we -// use this function to force all paths to serialize to string -// literals. -fn from_path>(path: P) -> Value { - format!(r#"'{}'"#, path.as_ref().as_str()) - .parse::() - .unwrap() + #[serde(deserialize_with = "deserialize_optional_unix_paths", default)] + pub files: Option>, } impl EnvProject { @@ -293,7 +290,7 @@ impl EnvProject { table.insert("name", value(name)); } table.insert("version", value(&self.version)); - table.insert("path", value(from_path(&self.path))); + table.insert("path", value(self.path.as_str())); if !self.identifiers.is_empty() { table.insert( "identifiers", @@ -307,7 +304,7 @@ impl EnvProject { table.insert("editable", value(true)); } if let Some(files) = &self.files { - let file_iter = files.iter().map(from_path); + let file_iter = files.iter().map(|f| f.as_str()); if files.is_empty() { table.insert("files", value(Array::from_iter(file_iter))); } else { diff --git a/core/src/lock.rs b/core/src/lock.rs index f96b6a52..93ba8676 100644 --- a/core/src/lock.rs +++ b/core/src/lock.rs @@ -19,7 +19,13 @@ use toml_edit::{ }; use typed_path::Utf8UnixPathBuf; -use crate::{env::ReadEnvironment, project::ProjectRead}; +use crate::{ + env::ReadEnvironment, + project::{ + ProjectRead, + utils::{deserialize_unix_path, serialize_unix_path}, + }, +}; pub const LOCKFILE_PREFIX: &str = "# This file is automatically generated by Sysand and is not intended to be edited manually.\n\n"; pub const CURRENT_LOCK_VERSION: &str = "0.3"; @@ -505,21 +511,21 @@ pub enum Source { // Path must be a Unix path relative to workspace root Editable { #[serde( - deserialize_with = "parse_unix_path", + deserialize_with = "deserialize_unix_path", serialize_with = "serialize_unix_path" )] editable: Utf8UnixPathBuf, }, LocalSrc { #[serde( - deserialize_with = "parse_unix_path", + deserialize_with = "deserialize_unix_path", serialize_with = "serialize_unix_path" )] src_path: Utf8UnixPathBuf, }, LocalKpar { #[serde( - deserialize_with = "parse_unix_path", + deserialize_with = "deserialize_unix_path", serialize_with = "serialize_unix_path" )] kpar_path: Utf8UnixPathBuf, @@ -542,22 +548,6 @@ pub enum Source { }, } -fn serialize_unix_path(x: &Utf8UnixPathBuf, s: S) -> Result -where - S: serde::Serializer, -{ - s.serialize_str(x.as_str()) -} - -fn parse_unix_path<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - let s = String::deserialize(deserializer)?; - // TODO: check that it is actually what we want - Ok(Utf8UnixPathBuf::from(s)) -} - impl Source { pub fn to_toml(&self) -> InlineTable { let mut table = InlineTable::new(); diff --git a/core/src/project/local_src.rs b/core/src/project/local_src.rs index b7a89fa7..313933a8 100644 --- a/core/src/project/local_src.rs +++ b/core/src/project/local_src.rs @@ -20,7 +20,7 @@ use crate::{ model::{InterchangeProjectInfoRaw, InterchangeProjectMetadataRaw}, project::{ ProjectMut, ProjectRead, - utils::{RelativizePathError, ToPathBuf, relativize_path, wrapfs}, + utils::{RelativizePathError, ToPathBuf, ToUnixPathBuf, relativize_path, wrapfs}, }, }; @@ -119,17 +119,7 @@ impl LocalSrcProject { let path = relativize_path_in(&path, project_path) .ok_or_else(|| UnixPathError::PathOutsideProject(path.to_path_buf()))?; - let mut unix_path = Utf8UnixPathBuf::new(); - for component in path.components() { - unix_path.push( - component - .as_os_str() - .to_str() - .ok_or_else(|| UnixPathError::Conversion(path.to_owned()))?, - ); - } - - Ok(unix_path) + Ok(path.to_unix_path_buf()) } pub fn get_source_path>( @@ -358,8 +348,6 @@ pub enum UnixPathError { PathOutsideProject(Utf8PathBuf), #[error("failed to canonicalize\n `{0}`:\n {1}")] Canonicalize(Utf8PathBuf, std::io::Error), - #[error("path `{0}` is not valid Unicode")] - Conversion(Utf8PathBuf), } #[derive(Error, Debug)] diff --git a/core/src/project/utils.rs b/core/src/project/utils.rs index 5ea010cd..83c05975 100644 --- a/core/src/project/utils.rs +++ b/core/src/project/utils.rs @@ -1,13 +1,15 @@ // SPDX-FileCopyrightText: © 2025 Sysand contributors // SPDX-License-Identifier: MIT OR Apache-2.0 +use std::io::{self, Read}; + use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; +use serde::Deserialize; use thiserror::Error; +use typed_path::Utf8UnixPathBuf; #[cfg(feature = "filesystem")] use zip::{self, result::ZipError}; -use std::io::{self, Read}; - /// A file that is guaranteed to exist as long as the lifetime. /// Intended to be used with temporary files that are automatically /// deleted; in this case, the lifetime `'a` is the lifetime of the @@ -45,6 +47,58 @@ where } } +pub trait ToUnixPathBuf { + fn to_unix_path_buf(&self) -> Utf8UnixPathBuf; +} + +impl

ToUnixPathBuf for P +where + P: AsRef, +{ + fn to_unix_path_buf(&self) -> Utf8UnixPathBuf { + let mut unix_path = Utf8UnixPathBuf::new(); + for component in self.as_ref().components() { + unix_path.push( + component + .as_os_str() + .to_str() + // This conversion should always be possible since everything is UTF8 encoded + .expect("component of `Utf8Path` no convertible to `str`"), + ); + } + unix_path + } +} + +pub fn serialize_unix_path(path: &Utf8UnixPathBuf, s: S) -> Result +where + S: serde::Serializer, +{ + s.serialize_str(path.as_str()) +} + +pub fn deserialize_unix_path<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let string = String::deserialize(deserializer)?; + Ok(Utf8UnixPathBuf::from(string)) +} + +pub fn deserialize_optional_unix_paths<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt = Option::>::deserialize(deserializer)?; + Ok(opt.map(|vec| { + vec.iter() + .map(Utf8UnixPathBuf::from) + .collect::>() + })) +} + /// The errors arising from filesystem I/O. /// The variants defined here include relevant context where possible. #[derive(Error, Debug)] diff --git a/sysand/tests/cli_sync.rs b/sysand/tests/cli_sync.rs index ee14f82b..e63c00ba 100644 --- a/sysand/tests/cli_sync.rs +++ b/sysand/tests/cli_sync.rs @@ -1,10 +1,7 @@ // SPDX-FileCopyrightText: © 2025 Sysand contributors // SPDX-License-Identifier: MIT OR Apache-2.0 -use std::str::FromStr; - use assert_cmd::prelude::*; -use camino::Utf8PathBuf; use indexmap::IndexMap; use mockito::Matcher; use predicates::prelude::*; @@ -55,10 +52,10 @@ version = "0.1" publisher = "untitled" name = "sync_to_current" version = "1.2.3" -path = '.' +path = "." editable = true files = [ - 'test.sysml', + "test.sysml", ] "# ) @@ -129,10 +126,6 @@ sources = [ .stderr(predicate::str::contains("Installing")); let env_metadata = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH))?; - let project_env_path = - Utf8PathBuf::from_str("5ddc0a2e8aaa88ac2bfc71aa0a8d08e020bceac4a90a4b72d8fb7f97ec5bfcc5") - .unwrap() - .join("1.2.3.kpar"); assert_eq!( env_metadata, @@ -144,7 +137,7 @@ version = "0.1" [[project]] name = "sync_to_local" version = "1.2.3" -path = '{project_env_path}' +path = "5ddc0a2e8aaa88ac2bfc71aa0a8d08e020bceac4a90a4b72d8fb7f97ec5bfcc5/1.2.3.kpar" identifiers = [ "urn:kpar:sync_to_local", ] @@ -221,10 +214,6 @@ sources = [ meta_mock.assert(); let env_metadata = std::fs::read_to_string(cwd.join(DEFAULT_ENV_NAME).join(METADATA_PATH))?; - let project_env_path = - Utf8PathBuf::from_str("2b95cb7c6d6c08695b0e7c4b7e9d836c21de37fb9c72b0cfa26f53fd84a1b459") - .unwrap() - .join("1.2.3.kpar"); assert_eq!( env_metadata, @@ -236,7 +225,7 @@ version = "0.1" [[project]] name = "sync_to_remote" version = "1.2.3" -path = '{project_env_path}' +path = "2b95cb7c6d6c08695b0e7c4b7e9d836c21de37fb9c72b0cfa26f53fd84a1b459/1.2.3.kpar" identifiers = [ "urn:kpar:sync_to_remote", ] From 03aca7e16710ee8368628ae5633663e8f809af88 Mon Sep 17 00:00:00 2001 From: "victor.linroth.sensmetry" Date: Tue, 24 Mar 2026 21:26:49 +0100 Subject: [PATCH 11/14] Add workspace field. Signed-off-by: victor.linroth.sensmetry --- core/src/context.rs | 10 ++++++++-- core/src/env/local_directory/metadata.rs | 25 +++++++++++++++++++++--- sysand/src/commands/add.rs | 1 + sysand/src/commands/clone.rs | 10 ++++++++++ sysand/src/commands/env.rs | 4 +++- sysand/src/commands/lock.rs | 4 ++-- sysand/src/commands/sync.rs | 4 +++- sysand/src/lib.rs | 6 ++++-- 8 files changed, 53 insertions(+), 11 deletions(-) diff --git a/core/src/context.rs b/core/src/context.rs index c5b6dec7..1f21e192 100644 --- a/core/src/context.rs +++ b/core/src/context.rs @@ -1,12 +1,18 @@ +#[cfg(feature = "filesystem")] +use camino::Utf8PathBuf; + #[cfg(feature = "filesystem")] use crate::{project::local_src::LocalSrcProject, workspace::Workspace}; #[derive(Debug, Default)] pub struct ProjectContext { - /// Root directory of current workspace + /// Current workspace if found #[cfg(feature = "filesystem")] pub current_workspace: Option, - /// Root directory of current project + /// Current project if found #[cfg(feature = "filesystem")] pub current_project: Option, + /// Path to current directory + #[cfg(feature = "filesystem")] + pub current_directory: Utf8PathBuf, } diff --git a/core/src/env/local_directory/metadata.rs b/core/src/env/local_directory/metadata.rs index 242894a0..50ea6e04 100644 --- a/core/src/env/local_directory/metadata.rs +++ b/core/src/env/local_directory/metadata.rs @@ -11,6 +11,7 @@ use typed_path::Utf8UnixPathBuf; use crate::{ commands::sources::{LocalSourcesError, do_sources_local_src_project_no_deps}, + context::ProjectContext, env::local_directory::{LocalDirectoryEnvironment, LocalReadError}, lock::{Lock, ResolutionError, Source, multiline_array}, project::{ @@ -37,10 +38,10 @@ pub enum LockToEnvMetadataError { } impl Lock { - pub fn to_env_metadata>( + pub fn to_env_metadata( &self, env: &LocalDirectoryEnvironment, - root_path: P, + ctx: &ProjectContext, ) -> Result { let resolved_projects = self.resolve_projects(env)?; @@ -68,9 +69,16 @@ impl Lock { usages, editable: false, files: None, + workspace: false, }); } else if let [Source::Editable { editable }, ..] = project.sources.as_slice() { - let root_path = wrapfs::canonicalize(root_path.as_ref())?; + let root_path = if let Some(current_workspace) = &ctx.current_workspace { + wrapfs::canonicalize(current_workspace.root_path())? + } else if let Some(current_project) = &ctx.current_project { + wrapfs::canonicalize(current_project.root_path())? + } else { + wrapfs::canonicalize(&ctx.current_directory)? + }; let editable_project = LocalSrcProject { nominal_path: None, project_path: wrapfs::canonicalize(root_path.join(editable.as_str()))?, @@ -83,6 +91,11 @@ impl Lock { .to_unix_path_buf() }) .collect(); + let workspace = ctx + .current_workspace + .iter() + .flat_map(|ws| ws.projects().iter()) + .any(|p| p.path.as_str() == editable); metadata.projects.push(EnvProject { publisher: project.publisher, name: project.name, @@ -92,6 +105,7 @@ impl Lock { usages, editable: true, files: Some(files), + workspace, }); } } @@ -212,6 +226,7 @@ impl EnvMetadata { identifiers: Vec, project: LocalSrcProject, editable: bool, + workspace: bool, ) -> Result<(), AddProjectError> { let info = project .get_info()? @@ -228,6 +243,7 @@ impl EnvMetadata { usages: info.usage.into_iter().map(|u| u.resource).collect(), editable, files: None, + workspace, }; self.add_project(project); @@ -278,6 +294,9 @@ pub struct EnvProject { /// to the `path` of the project. #[serde(deserialize_with = "deserialize_optional_unix_paths", default)] pub files: Option>, + /// Indicator of wether the project is part of a workspace. + #[serde(skip_serializing_if = "bool::is_false", default)] + pub workspace: bool, } impl EnvProject { diff --git a/sysand/src/commands/add.rs b/sysand/src/commands/add.rs index da4d3eea..9194ca2b 100644 --- a/sysand/src/commands/add.rs +++ b/sysand/src/commands/add.rs @@ -280,6 +280,7 @@ fn resolve_deps, Policy: HTTPAuthentication>( &provided_iris, runtime, auth_policy, + ctx, )?; } Ok(()) diff --git a/sysand/src/commands/clone.rs b/sysand/src/commands/clone.rs index 9db2229a..cfce6f2a 100644 --- a/sysand/src/commands/clone.rs +++ b/sysand/src/commands/clone.rs @@ -91,6 +91,15 @@ pub fn command_clone( } }; + // Update project context with the new cloned project + // TODO: Consider under which circumstances (if any) + // the workspace should carry over. + let ctx = ProjectContext { + current_workspace: None, + current_project: Some(local_project.clone()), + current_directory: ctx.current_directory, + }; + if !no_deps { let provided_iris = if !include_std { crate::known_std_libs() @@ -147,6 +156,7 @@ pub fn command_clone( &provided_iris, runtime, auth_policy, + &ctx, )?; } diff --git a/sysand/src/commands/env.rs b/sysand/src/commands/env.rs index 2f883935..cc3dcb68 100644 --- a/sysand/src/commands/env.rs +++ b/sysand/src/commands/env.rs @@ -165,6 +165,7 @@ pub fn command_env_install( &provided_iris, runtime, auth_policy, + &ctx, )?; } @@ -301,6 +302,7 @@ pub fn command_env_install_path( &provided_iris, runtime, auth_policy, + &ctx, )?; } else { add_single_env_project(iri, project_version, env)?; @@ -321,7 +323,7 @@ fn add_single_env_project, V: AsRef>( nominal_path: Some(project_path.strip_prefix(env.root_path())?.to_owned()), project_path, }; - env_metadata.add_local_project(vec![iri.as_ref().to_owned()], project, false)?; + env_metadata.add_local_project(vec![iri.as_ref().to_owned()], project, false, false)?; wrapfs::write(metadata_path, env_metadata.to_string())?; Ok(()) diff --git a/sysand/src/commands/lock.rs b/sysand/src/commands/lock.rs index 718f448e..a64f9dea 100644 --- a/sysand/src/commands/lock.rs +++ b/sysand/src/commands/lock.rs @@ -37,7 +37,7 @@ pub fn command_lock, Policy: HTTPAuthentication, R: AsRef, auth_policy: Arc, - ctx: ProjectContext, + ctx: &ProjectContext, ) -> Result { assert!(path.as_ref().is_relative(), "{}", path.as_ref()); @@ -75,7 +75,7 @@ pub fn command_lock, Policy: HTTPAuthentication, R: AsRef, Policy: HTTPAuthentication>( provided_iris: &HashMap>, runtime: Arc, auth_policy: Arc, + ctx: &ProjectContext, ) -> Result<()> { sysand_core::commands::sync::do_sync( lock, @@ -68,7 +70,7 @@ pub fn command_sync, Policy: HTTPAuthentication>( provided_iris, )?; - let lock_metadata = lock.to_env_metadata(env, project_root)?; + let lock_metadata = lock.to_env_metadata(env, ctx)?; let env_metadata = if wrapfs::is_file(env.metadata_path())? { let mut env_metadata = load_env_metadata(env.metadata_path())?; env_metadata.merge(lock_metadata); diff --git a/sysand/src/lib.rs b/sysand/src/lib.rs index 21ad9800..068809e6 100644 --- a/sysand/src/lib.rs +++ b/sysand/src/lib.rs @@ -147,6 +147,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { let ctx = ProjectContext { current_workspace: discover_workspace(&cwd)?, current_project: discover_project(&cwd)?, + current_directory: cwd.clone(), }; let project_root = ctx .current_project @@ -369,7 +370,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { client, runtime, basic_auth_policy, - ctx, + &ctx, ) .map(|_| ()) } else { @@ -409,7 +410,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { client.clone(), runtime.clone(), basic_auth_policy.clone(), - ctx, + &ctx, )? } else { bail!("failed to read lockfile `{lockfile}`: {e}") @@ -424,6 +425,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { &provided_iris, runtime, basic_auth_policy, + &ctx, ) } Command::PrintRoot => command_print_root(cwd), From 2da7526d7462e81acc5cd68049441577f375e5f0 Mon Sep 17 00:00:00 2001 From: "victor.linroth.sensmetry" Date: Fri, 27 Mar 2026 10:56:53 +0100 Subject: [PATCH 12/14] Address feedback. Signed-off-by: victor.linroth.sensmetry --- Cargo.lock | 12 +----------- core/Cargo.toml | 2 +- core/src/env/local_directory/metadata.rs | 21 +++++++++++++++------ core/src/lock.rs | 8 -------- core/src/project/utils.rs | 15 ++++++--------- sysand/src/commands/sync.rs | 3 +++ sysand/src/lib.rs | 19 ++++++++++++------- 7 files changed, 38 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ca63faaf..ee1d5626 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2409,16 +2409,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "packageurl" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35da99768af1ae8830ccf30d295db0e09c24bcfda5a67515191dd4b773f6d82a" -dependencies = [ - "percent-encoding", - "thiserror 2.0.18", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -3360,6 +3350,7 @@ dependencies = [ "chrono", "digest", "dirs", + "dunce", "fluent-uri", "futures", "gix", @@ -3368,7 +3359,6 @@ dependencies = [ "log", "logos", "mockito", - "packageurl", "port_check", "predicates", "pubgrub", diff --git a/core/Cargo.toml b/core/Cargo.toml index 37988a1c..db781846 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -68,7 +68,7 @@ bytes = { version = "1.11.1", default-features = false } toml_edit = { version = "0.25.4", features = ["serde"] } globset = { version = "0.4.18", default-features = false } reqwest = { version = "0.13.2", optional = true, features = ["rustls", "stream"] } -packageurl = "0.6.0" +dunce = "1.0.5" [dev-dependencies] assert_cmd = "2.1.2" diff --git a/core/src/env/local_directory/metadata.rs b/core/src/env/local_directory/metadata.rs index 50ea6e04..bf1cb6b9 100644 --- a/core/src/env/local_directory/metadata.rs +++ b/core/src/env/local_directory/metadata.rs @@ -73,11 +73,11 @@ impl Lock { }); } else if let [Source::Editable { editable }, ..] = project.sources.as_slice() { let root_path = if let Some(current_workspace) = &ctx.current_workspace { - wrapfs::canonicalize(current_workspace.root_path())? + current_workspace.root_path() } else if let Some(current_project) = &ctx.current_project { - wrapfs::canonicalize(current_project.root_path())? + current_project.root_path() } else { - wrapfs::canonicalize(&ctx.current_directory)? + &ctx.current_directory }; let editable_project = LocalSrcProject { nominal_path: None, @@ -86,7 +86,7 @@ impl Lock { let files = do_sources_local_src_project_no_deps(&editable_project, true)? .into_iter() .map(|path| { - relativize_path(path, root_path.clone()) + relativize_path(path, root_path) .expect("cannot relativize path to file in editable project") .to_unix_path_buf() }) @@ -273,7 +273,7 @@ pub struct EnvProject { #[serde(deserialize_with = "deserialize_unix_path")] pub path: Utf8UnixPathBuf, /// List of identifiers (IRIs) used for the project. - /// The first identifier is to. be considered the canonical + /// The first identifier is considered the canonical /// identifier, and if the project is not `editable` this /// is the IRI it is installed as. The rest are considered /// as aliases. Can only be empty for `editable` projects. @@ -294,7 +294,8 @@ pub struct EnvProject { /// to the `path` of the project. #[serde(deserialize_with = "deserialize_optional_unix_paths", default)] pub files: Option>, - /// Indicator of wether the project is part of a workspace. + /// Indicator of whether the project is part of the current + /// workspace. #[serde(skip_serializing_if = "bool::is_false", default)] pub workspace: bool, } @@ -334,7 +335,15 @@ impl EnvProject { table } + /// Adds identifiers from other project. + /// Should only be done if the underlying projects are the same. + /// In particular they must have the same version. pub fn merge(&mut self, other: &EnvProject) { + assert_eq!( + self.version, other.version, + "attempting to merge projects with different versions" + ); + for iri in &other.identifiers { if !self.identifiers.contains(iri) { self.identifiers.push(iri.clone()); diff --git a/core/src/lock.rs b/core/src/lock.rs index 93ba8676..405b14e3 100644 --- a/core/src/lock.rs +++ b/core/src/lock.rs @@ -10,7 +10,6 @@ use std::{ }; use fluent_uri::Iri; -use packageurl::PackageUrl; use semver::Version; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -484,13 +483,6 @@ impl Project { self.hash(&mut hasher); ProjectHash(hasher.finish()) } - - // Simple stopgap solution for now - pub fn get_package_url<'a>(&self) -> Option> { - self.identifiers - .first() - .and_then(|id| PackageUrl::from_str(id.as_str()).ok()) - } } const SOURCE_ENTRIES: &[&str] = &[ diff --git a/core/src/project/utils.rs b/core/src/project/utils.rs index 83c05975..abc9d8d3 100644 --- a/core/src/project/utils.rs +++ b/core/src/project/utils.rs @@ -58,13 +58,7 @@ where fn to_unix_path_buf(&self) -> Utf8UnixPathBuf { let mut unix_path = Utf8UnixPathBuf::new(); for component in self.as_ref().components() { - unix_path.push( - component - .as_os_str() - .to_str() - // This conversion should always be possible since everything is UTF8 encoded - .expect("component of `Utf8Path` no convertible to `str`"), - ); + unix_path.push(component.as_str()); } unix_path } @@ -241,8 +235,11 @@ pub mod wrapfs { } pub fn canonicalize>(path: P) -> Result> { - path.as_ref() - .canonicalize_utf8() + dunce::canonicalize(path.as_ref()) + .map(|path| { + Utf8PathBuf::from_path_buf(path) + .expect("expected Dunce not to introduce non UTF8 characters") + }) .map_err(|e| Box::new(FsIoError::Canonicalize(path.as_ref().into(), e))) } diff --git a/sysand/src/commands/sync.rs b/sysand/src/commands/sync.rs index 582caaf9..969725dc 100644 --- a/sysand/src/commands/sync.rs +++ b/sysand/src/commands/sync.rs @@ -70,6 +70,9 @@ pub fn command_sync, Policy: HTTPAuthentication>( provided_iris, )?; + // TODO: Integrate the updating of metadata into `LocalDirectoryEnvironment` itself. + // This will likely require updating the `WriteEnvironment` trait to support + // multiple identifiers per project. let lock_metadata = lock.to_env_metadata(env, ctx)?; let env_metadata = if wrapfs::is_file(env.metadata_path())? { let mut env_metadata = load_env_metadata(env.metadata_path())?; diff --git a/sysand/src/lib.rs b/sysand/src/lib.rs index 068809e6..2e47df5e 100644 --- a/sysand/src/lib.rs +++ b/sysand/src/lib.rs @@ -147,7 +147,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { let ctx = ProjectContext { current_workspace: discover_workspace(&cwd)?, current_project: discover_project(&cwd)?, - current_directory: cwd.clone(), + current_directory: cwd, }; let project_root = ctx .current_project @@ -155,7 +155,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { .map(|p| p.root_path().to_owned()); let current_environment = { - let dir = project_root.as_ref().unwrap_or(&cwd); + let dir = project_root.as_ref().unwrap_or(&ctx.current_directory); crate::get_env(dir)? }; @@ -283,7 +283,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { Command::Env { command } => match command { None => { let env_dir = { - let mut p = project_root.unwrap_or(cwd); + let mut p = project_root.unwrap_or(ctx.current_directory); p.push(DEFAULT_ENV_NAME); p }; @@ -382,7 +382,12 @@ pub fn run_cli(args: cli::Args) -> Result<()> { Command::Sync { resolution_opts } => { let mut local_environment = match current_environment { Some(env) => env, - None => command_env(project_root.as_ref().unwrap_or(&cwd).join(DEFAULT_ENV_NAME))?, + None => command_env( + project_root + .as_ref() + .unwrap_or(&ctx.current_directory) + .join(DEFAULT_ENV_NAME), + )?, }; let provided_iris = if !resolution_opts.include_std { @@ -392,7 +397,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { HashMap::default() }; - let project_root = project_root.unwrap_or(cwd); + let project_root = project_root.unwrap_or(ctx.current_directory.clone()); let lockfile = project_root.join(DEFAULT_LOCKFILE_NAME); let lock = match fs::read_to_string(&lockfile) { Ok(l) => match Lock::from_str(&l) { @@ -428,7 +433,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { &ctx, ) } - Command::PrintRoot => command_print_root(cwd), + Command::PrintRoot => command_print_root(ctx.current_directory), Command::Info { path, iri, @@ -472,7 +477,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { HashSet::default() }; - let project_root = project_root.unwrap_or(cwd); + let project_root = project_root.unwrap_or(ctx.current_directory); let overrides = get_overrides( &config, &project_root, From 1f9b048c6e8460955c3c6b6d6367aa9de10044e1 Mon Sep 17 00:00:00 2001 From: "victor.linroth.sensmetry" Date: Fri, 27 Mar 2026 11:11:52 +0100 Subject: [PATCH 13/14] Remove `files` field. Signed-off-by: victor.linroth.sensmetry --- core/src/env/local_directory/metadata.rs | 44 ++---------------------- core/src/project/utils.rs | 14 -------- sysand/tests/cli_sync.rs | 3 -- 3 files changed, 2 insertions(+), 59 deletions(-) diff --git a/core/src/env/local_directory/metadata.rs b/core/src/env/local_directory/metadata.rs index bf1cb6b9..0d6dc446 100644 --- a/core/src/env/local_directory/metadata.rs +++ b/core/src/env/local_directory/metadata.rs @@ -6,20 +6,16 @@ use std::{fmt::Display, str::FromStr}; use camino::{Utf8Path, Utf8PathBuf}; use serde::Deserialize; use thiserror::Error; -use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, Value, value}; +use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, Value, value}; use typed_path::Utf8UnixPathBuf; use crate::{ - commands::sources::{LocalSourcesError, do_sources_local_src_project_no_deps}, context::ProjectContext, env::local_directory::{LocalDirectoryEnvironment, LocalReadError}, lock::{Lock, ResolutionError, Source, multiline_array}, project::{ local_src::{LocalSrcError, LocalSrcProject}, - utils::{ - FsIoError, ToUnixPathBuf, deserialize_optional_unix_paths, deserialize_unix_path, - relativize_path, wrapfs, - }, + utils::{FsIoError, ToUnixPathBuf, deserialize_unix_path, wrapfs}, }, }; @@ -32,8 +28,6 @@ pub enum LockToEnvMetadataError { #[error(transparent)] ResolutionError(#[from] ResolutionError), #[error(transparent)] - LocalSources(#[from] LocalSourcesError), - #[error(transparent)] Canonicalization(#[from] Box), } @@ -68,29 +62,9 @@ impl Lock { identifiers: project.identifiers, usages, editable: false, - files: None, workspace: false, }); } else if let [Source::Editable { editable }, ..] = project.sources.as_slice() { - let root_path = if let Some(current_workspace) = &ctx.current_workspace { - current_workspace.root_path() - } else if let Some(current_project) = &ctx.current_project { - current_project.root_path() - } else { - &ctx.current_directory - }; - let editable_project = LocalSrcProject { - nominal_path: None, - project_path: wrapfs::canonicalize(root_path.join(editable.as_str()))?, - }; - let files = do_sources_local_src_project_no_deps(&editable_project, true)? - .into_iter() - .map(|path| { - relativize_path(path, root_path) - .expect("cannot relativize path to file in editable project") - .to_unix_path_buf() - }) - .collect(); let workspace = ctx .current_workspace .iter() @@ -104,7 +78,6 @@ impl Lock { identifiers: project.identifiers, usages, editable: true, - files: Some(files), workspace, }); } @@ -242,7 +215,6 @@ impl EnvMetadata { identifiers, usages: info.usage.into_iter().map(|u| u.resource).collect(), editable, - files: None, workspace, }; self.add_project(project); @@ -292,10 +264,6 @@ pub struct EnvProject { /// are not able to natively parse and understand the /// projects `.meta.json` file. Paths should be relative /// to the `path` of the project. - #[serde(deserialize_with = "deserialize_optional_unix_paths", default)] - pub files: Option>, - /// Indicator of whether the project is part of the current - /// workspace. #[serde(skip_serializing_if = "bool::is_false", default)] pub workspace: bool, } @@ -323,14 +291,6 @@ impl EnvProject { if self.editable { table.insert("editable", value(true)); } - if let Some(files) = &self.files { - let file_iter = files.iter().map(|f| f.as_str()); - if files.is_empty() { - table.insert("files", value(Array::from_iter(file_iter))); - } else { - table.insert("files", value(multiline_array(file_iter))); - } - } table } diff --git a/core/src/project/utils.rs b/core/src/project/utils.rs index abc9d8d3..e7702a94 100644 --- a/core/src/project/utils.rs +++ b/core/src/project/utils.rs @@ -79,20 +79,6 @@ where Ok(Utf8UnixPathBuf::from(string)) } -pub fn deserialize_optional_unix_paths<'de, D>( - deserializer: D, -) -> Result>, D::Error> -where - D: serde::Deserializer<'de>, -{ - let opt = Option::>::deserialize(deserializer)?; - Ok(opt.map(|vec| { - vec.iter() - .map(Utf8UnixPathBuf::from) - .collect::>() - })) -} - /// The errors arising from filesystem I/O. /// The variants defined here include relevant context where possible. #[derive(Error, Debug)] diff --git a/sysand/tests/cli_sync.rs b/sysand/tests/cli_sync.rs index e63c00ba..9e6cac14 100644 --- a/sysand/tests/cli_sync.rs +++ b/sysand/tests/cli_sync.rs @@ -54,9 +54,6 @@ name = "sync_to_current" version = "1.2.3" path = "." editable = true -files = [ - "test.sysml", -] "# ) ); From 8a0772d0e8b80aaa7ee680e558937afc03c09f8a Mon Sep 17 00:00:00 2001 From: "victor.linroth.sensmetry" Date: Mon, 30 Mar 2026 11:17:22 +0200 Subject: [PATCH 14/14] Address more feedback. Signed-off-by: victor.linroth.sensmetry --- core/src/env/local_directory/metadata.rs | 19 +++++++++++-------- core/src/project/utils.rs | 7 +++++-- sysand/src/commands/env.rs | 9 +++++++-- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/core/src/env/local_directory/metadata.rs b/core/src/env/local_directory/metadata.rs index 0d6dc446..08d2c8b4 100644 --- a/core/src/env/local_directory/metadata.rs +++ b/core/src/env/local_directory/metadata.rs @@ -90,7 +90,7 @@ impl Lock { #[derive(Debug, Deserialize)] pub struct EnvMetadata { pub version: String, - #[serde(rename = "project", skip_serializing_if = "Vec::is_empty", default)] + #[serde(rename = "project", default)] pub projects: Vec, } @@ -176,7 +176,7 @@ impl EnvMetadata { pub fn add_project(&mut self, project: EnvProject) { if let Some(found) = self.find_project(&project.identifiers, &project.version) { - self.projects[found].merge(&project); + self.projects[found].merge_identifiers(&project); } else { self.projects.push(project); } @@ -249,22 +249,22 @@ pub struct EnvProject { /// identifier, and if the project is not `editable` this /// is the IRI it is installed as. The rest are considered /// as aliases. Can only be empty for `editable` projects. - #[serde(skip_serializing_if = "Vec::is_empty", default)] + #[serde(default)] pub identifiers: Vec, /// Usages of the project. Intended for tools needing to /// track the interdependence of project in the environment. - #[serde(skip_serializing_if = "Vec::is_empty", default)] + #[serde(default)] pub usages: Vec, /// Indicator of wether the project is fully installed in /// the environment or located elsewhere. - #[serde(skip_serializing_if = "bool::is_false", default)] + #[serde(default)] pub editable: bool, /// In case of an `editable` project these are the files /// belonging to the project. Intended for tools that /// are not able to natively parse and understand the /// projects `.meta.json` file. Paths should be relative /// to the `path` of the project. - #[serde(skip_serializing_if = "bool::is_false", default)] + #[serde(default)] pub workspace: bool, } @@ -291,6 +291,9 @@ impl EnvProject { if self.editable { table.insert("editable", value(true)); } + if self.workspace { + table.insert("workspace", value(true)); + } table } @@ -298,10 +301,10 @@ impl EnvProject { /// Adds identifiers from other project. /// Should only be done if the underlying projects are the same. /// In particular they must have the same version. - pub fn merge(&mut self, other: &EnvProject) { + pub fn merge_identifiers(&mut self, other: &EnvProject) { assert_eq!( self.version, other.version, - "attempting to merge projects with different versions" + "attempting to merge identifiers for projects with different versions" ); for iri in &other.identifiers { diff --git a/core/src/project/utils.rs b/core/src/project/utils.rs index e7702a94..b74bc0f8 100644 --- a/core/src/project/utils.rs +++ b/core/src/project/utils.rs @@ -220,11 +220,14 @@ pub mod wrapfs { .map_err(|e| Box::new(FsIoError::WriteFile(path.as_ref().into(), e))) } + /// Canonicalizes UTF-8 path. If canonicalized path is not valid + /// UTF-8, returns `io::Error` of `InvalidData` kind. + /// On Windows this returns most compatible form of a path instead of UNC. pub fn canonicalize>(path: P) -> Result> { dunce::canonicalize(path.as_ref()) - .map(|path| { + .and_then(|path| { Utf8PathBuf::from_path_buf(path) - .expect("expected Dunce not to introduce non UTF8 characters") + .map_err(|_| io::Error::from(io::ErrorKind::InvalidData)) }) .map_err(|e| Box::new(FsIoError::Canonicalize(path.as_ref().into(), e))) } diff --git a/sysand/src/commands/env.rs b/sysand/src/commands/env.rs index cc3dcb68..2211066e 100644 --- a/sysand/src/commands/env.rs +++ b/sysand/src/commands/env.rs @@ -13,7 +13,10 @@ use sysand_core::{ commands::{env::do_env_local_dir, lock::LockOutcome}, config::Config, context::ProjectContext, - env::local_directory::{LocalDirectoryEnvironment, metadata::load_env_metadata}, + env::local_directory::{ + LocalDirectoryEnvironment, + metadata::{EnvMetadata, load_env_metadata}, + }, lock::Lock, model::InterchangeProjectUsage, project::{ @@ -36,7 +39,9 @@ use crate::{ }; pub fn command_env>(path: P) -> Result { - Ok(do_env_local_dir(path)?) + let env = do_env_local_dir(path)?; + wrapfs::write(env.metadata_path(), EnvMetadata::default().to_string())?; + Ok(env) } // TODO: Factor out provided_iris logic