Skip to content

feat: configuration of project sources#130

Open
victor-linroth-sensmetry wants to merge 26 commits intomainfrom
feat/config-sources
Open

feat: configuration of project sources#130
victor-linroth-sensmetry wants to merge 26 commits intomainfrom
feat/config-sources

Conversation

@victor-linroth-sensmetry
Copy link
Collaborator

@victor-linroth-sensmetry victor-linroth-sensmetry commented Nov 28, 2025

Main new features added by this PR:

  • Config options for project sources in sysand.toml (or given config). Allows for portable specification of local and remote sources.
  • Command line options --editable, --path, --url-src and --url-kpar for sysand add that automatically updates config file. These also get removed with sysand remove.
  • Documentation explaining the above.

In order to simplify implementing the above the core library was build out with the following changes:

  • New macro crate with ProjectRead and ProjectMut derive macros for easy enum composition of project structs.
  • New CachedProject<Local, Remote> struct for when the Local project is easier to access (e.g. on disk or in memory) vs Remote, but you still want .sources() to give the remote sources.
  • Implementation of CombinedResolver simplified using derive(ProjectRead) and CachedProject.
  • New ProjectReference<Project> struct. When working with ReadEnvironment the projects need to be clonable, but this might not always be a good idea, e.g. some project hold ownership of temp dirs. ProjectReference is basically and Arc wrapper for a project to be used as a compatibility layer.
  • New AnyProject struct combining the different projects supported for source configuration. Should be extended to include Git projects in future.
  • LocalSrcProject and LocalKparProject now has optional nominal paths in addition to the regular path. This comes from the choice to have configured source always be relative to the project root but this not always being available everywhere. (Could perhaps be simplified if we added a Global Context).

In addition there where some small changes that came about when experimenting with different solutions:

  • MemoryEnv can now be used with a generic project.
  • MemoryResolver can be conveniently constructed from iterator/array/Vec.

These ended up not being used here, but they could be useful at some later point so where left in.

Lastly, the verbose/quiet options were removed from the config. They were mostly placeholders to begin with and now that there are more real options present in the config they don't serve much of a purpose. Removing them also means that the logger can be initialized almost immediately on start, opening up the potential for logging more things, like e.g. the loading of the config files.

@tilowiklundSensmetry
Copy link
Member

Is there a particular reason for expanding CombinedResolver with an Override case, as opposed to having a generic OverrideResolver<T> and use StandardResolver = OverrideResolver<CombinedResolver>?

@victor-linroth-sensmetry
Copy link
Collaborator Author

Is there a particular reason for expanding CombinedResolver with an Override case, as opposed to having a generic OverrideResolver<T> and use StandardResolver = OverrideResolver<CombinedResolver>?

No particular reason, just the first thing that came to mind. Do you think that would be a better solution?

@tilowiklundSensmetry
Copy link
Member

Is there a particular reason for expanding CombinedResolver with an Override case, as opposed to having a generic OverrideResolver<T> and use StandardResolver = OverrideResolver<CombinedResolver>?

No particular reason, just the first thing that came to mind. Do you think that would be a better solution?

It might be easier to test/mock, and be a bit easier to reuse elsewhere. You can simply have OverrideResolver<Memory/Null> instead of having to create a CombinedResolver<Memory/Null, Memory/Null, Memory/Null, Override, Memory/Null>.

No very strong preference though, so don't bother if it's a lot of work to refactor.

@tilowiklundSensmetry
Copy link
Member

Everything looks good to me in principle, would be a good to have a proper code review from @andrius-puksta-sensmetry before merging though.

@victor-linroth-sensmetry
Copy link
Collaborator Author

No very strong preference though, so don't bother if it's a lot of work to refactor.

Well, this was precisely the type of feedback I was looking for, so I'll definitely consider it.

Everything looks good to me in principle, would be a good to have a proper code review from @andrius-puksta-sensmetry before merging though.

It's still a draft so far. I want to add some quality of life command line stuff also.

@victor-linroth-sensmetry victor-linroth-sensmetry changed the title Feat/config sources feat: configuration of project sources Dec 29, 2025
@victor-linroth-sensmetry
Copy link
Collaborator Author

victor-linroth-sensmetry commented Jan 20, 2026

Currently sysand_core::lock::Source and typed_path::Utf8UnixPath have to be in scope when using the ProjectRead macro. This is certainly less than ideal but it's not obvious to me what the best solution would be.

The problem stem from the fact that reference of types in macros should be absolute but the absolute paths vary depending on if you are inside or outside the crate. E.g. it's ::sysand_core::lock::Source if outside and ::crate::lock::Source if inside. If typed_path::Utf8UnixPath is part of the public API it should probably be reexported by sysand_core and then it's the same problem there.

One could try to add them as associated types to ProjectRead but then you introduce the additional complexity that it can vary with trait implementations. I'm unsure if thats desirable or necessary.

Another solution would be to refactor the crates so that they are always used outside of the crate where they are exported. Would essentially mean that all the project stuff goes into it's own crate (except for AnyProject which is derived using the macro).

Edit: I think the appropriate course of action is to factor out the projects into their own crate, but this would be a pretty wide reaching change so best to do as a separate PR after this is merged.

@victor-linroth-sensmetry
Copy link
Collaborator Author

Unsure if core/tests/project_no_derive.rs has any value left now that I have it working.

@victor-linroth-sensmetry
Copy link
Collaborator Author

victor-linroth-sensmetry commented Jan 20, 2026

Still left to make sure Windows paths are handled properly (don't think they are at the moment).

Edit: Since the sources only have paths that are Utf8UnixPaths, I think this should be covered.

Signed-off-by: victor.linroth.sensmetry <victor.linroth@sensmetry.com>
Signed-off-by: victor.linroth.sensmetry <victor.linroth@sensmetry.com>
Signed-off-by: victor.linroth.sensmetry <victor.linroth@sensmetry.com>
Signed-off-by: victor.linroth.sensmetry <victor.linroth@sensmetry.com>
Signed-off-by: victor.linroth.sensmetry <victor.linroth@sensmetry.com>
/// re-interpreted as filesystem-native paths relative to `project_path`.
#[derive(Clone, Debug)]
pub struct LocalSrcProject {
pub nominal_path: Option<Utf8PathBuf>,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this an Option?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only needed if .sources() is called, otherwise unnecessary.

Comment on lines +37 to +40
- `--editable <EDITABLE>`: Path to local editable interchange project
- `--path <PATH>`: Path to local interchange project
- `--url-src <URL_SRC>`: URL to remote interchange project
- `--url-kpar <URL_KPAR>`: URL to remote interchange project archive (KPAR)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update flag names


let mut local_project = LocalSrcProject { project_path };
let mut local_project = LocalSrcProject {
nominal_path: Some(project_path.clone()),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is incorrect. project_path is absolute (line 77), and nominal_path must be relative to workspace root.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless nominal_path here means something different.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's basically whatever path you want to be returned when calling .sources() on the project, so not necessarily relative to workspace root (although that might be the only usage at the moment).

I think this would be better solved if we had some form of Global Context object that could be used when calling .sources() so the caller gets some control over what type of path they want project represented by. Would make try_from_source simpler too. Such an object would require quite the bit of refactoring though so better done as a separate PR.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, this particular seems to be unnecessary and can just be None.

Comment on lines +1277 to +1288
/// Path to local editable interchange project
#[arg(long, group = "source")]
pub as_editable: Option<String>,
/// Path to local interchange project
#[arg(long, group = "source")]
pub as_path: Option<String>,
/// URL to remote interchange project
#[arg(long, group = "source")]
pub as_url_src: Option<String>,
/// URL to remote interchange project archive (KPAR)
#[arg(long, group = "source")]
pub as_url_kpar: Option<String>,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should mention here that these add a custom source to config file.

MemoryResolver::from(overrides),
standard_resolver(
cwd,
if local_env_path.is_dir() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is eating errors. Maybe we should add is_file() and is_dir() to wrapfs, but make them e.g. fn is_file() -> Result<bool, SomeError>? This is mostly a nice-to-have, so can be done later.

Comment on lines +85 to +100
let mut overrides = Vec::new();
for config_project in &config.projects {
for identifier in &config_project.identifiers {
let mut projects = Vec::new();
for source in &config_project.sources {
projects.push(ProjectReference::new(AnyProject::try_from_source(
source.clone(),
project_root.clone(),
auth_policy.clone(),
client.clone(),
runtime.clone(),
)?));
}
overrides.push((Iri::parse(identifier.as_str())?.into(), projects));
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this out into a function, it's done almost identically in 4 places

Comment on lines +204 to 205
nominal_path: Some(path.as_str().into()),
project_path: path.as_str().into(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nominal_path should be made relative to sysand_env parent. Since it's not currently done in most places, a comment would be fine for now.

Comment on lines +275 to 285
let override_resolver = PriorityResolver::new(
MemoryResolver::from(overrides),
MemoryResolver {
iri_predicate: AcceptAll {},
projects: memory_projects,
},
);
// TODO: Move out the runtime
let resolver = PriorityResolver::new(
override_resolver,
standard_resolver(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok for now, but if we add one more layer of PriorityResolver, something will have to be done.

Ok(src_path)
}

fn relativize(path: &Utf8Path, root: &Utf8Path) -> Utf8PathBuf {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fn relativize(path: &Utf8Path, root: &Utf8Path) -> Utf8PathBuf {
/// Create a relative path from `root` to `path`. Both of them must either
/// be absolute or otherwise start with the same prefix.
fn relativize(path: &Utf8Path, root: &Utf8Path) -> Utf8PathBuf {

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also maybe return Option<Utf8PathBuf> so that caller can decide what to do in case relative path cannot be constructed.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is useful (i.e. should be used, but currently isn't) in lots of places, so it should be moved to some utils file.

// If prefixes (e.g. C: vs D: on Windows) differ, no relative path is possible.
if path.components().next() != root.components().next() {
return path.to_path_buf();
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what to do about Windows UNC vs non-UNC paths, as they always have different first components. Relevant here is that std's canonicalize returns UNC paths on Windows, but calling that on both inputs here would be kind of wasteful.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Windows root-relative paths are also not handled correctly here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be a good idea after all to canonicalize both paths (or ensuring only canonical paths are given as args here) to avoid subtle bugs due to symlinks and Windows weirndess.

Comment on lines +61 to +70
Source::Editable { editable } => {
let nominal_path = editable.to_path_buf();
let project = LocalSrcProject {
nominal_path: Some(nominal_path.to_string().into()),
project_path: project_root.as_ref().join(nominal_path.as_str()),
};
Ok(AnyProject::Editable(
EditableProject::<LocalSrcProject>::new(nominal_path.as_str().into(), project),
))
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Source::Editable { editable } => {
let nominal_path = editable.to_path_buf();
let project = LocalSrcProject {
nominal_path: Some(nominal_path.to_string().into()),
project_path: project_root.as_ref().join(nominal_path.as_str()),
};
Ok(AnyProject::Editable(
EditableProject::<LocalSrcProject>::new(nominal_path.as_str().into(), project),
))
}
Source::Editable {
editable: nominal_path,
} => {
let project = LocalSrcProject {
nominal_path: Some(nominal_path.to_string().into()),
project_path: project_root.as_ref().join(nominal_path.as_str()),
};
Ok(AnyProject::Editable(
EditableProject::<LocalSrcProject>::new(
nominal_path.into_string().into(),
project,
),
))
}

.map_err(TryFromSourceError::LocalKpar)?,
)),
Source::LocalSrc { src_path } => {
let nominal_path = src_path.as_str().into();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let nominal_path = src_path.as_str().into();
let nominal_path = src_path.into_string().into();

Comment on lines +106 to +109
_ => Err(TryFromSourceError::UnsupportedSource(format!(
"{:?}",
source
))),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_ => Err(TryFromSourceError::UnsupportedSource(format!(
"{:?}",
source
))),
_ => Err(TryFromSourceError::UnsupportedSource(format!("{source:?}"))),

Comment on lines +254 to +257
return Ok(ResolutionOutcome::Unresolvable(format!(
"failed to resolve as file: {:?}",
msg,
)));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return Ok(ResolutionOutcome::Unresolvable(format!(
"failed to resolve as file: {:?}",
msg,
)));
return Ok(ResolutionOutcome::Unresolvable(format!(
"failed to resolve as file: {msg}",
)));


Sometimes you may wish to use a project that isn't resolvable through an
available index or you want to override the dependency resolution for other
reasons. In any case you can do this by adding the appropriate IRI and `Source`
Copy link
Collaborator

@andrius-puksta-sensmetry andrius-puksta-sensmetry Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
reasons. In any case you can do this by adding the appropriate IRI and `Source`
reasons. You may also just want to give the usage a different identifier for readability. In any case you can do this by adding the appropriate IRI and `sources`

reasons. In any case you can do this by adding the appropriate IRI and `Source`
to a `project` entry in the `sysand.toml` configuration file at the root of
your project. This follows the same structure as found in the lockfile, where
`identifiers` are given as a list of IRI:s and `sources` are a list of sources.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`identifiers` are given as a list of IRI:s and `sources` are a list of sources.
`identifiers` are given as a list of IRIs and `sources` are a list of sources.

Not sure what the correct way is.

A project may have multiple identifiers in case it is referred to differently
by different projects, and multiple sources where the additional ones after the
first serve as backups in case the previous ones fail to resolve. Note that
these should be sources of the exact same project as determined by it's
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
these should be sources of the exact same project as determined by it's
these should be sources of the exact same project as determined by its

checksum, as otherwise you are likely to run into problems when syncing against
a lockfile.

Below we describe how add overriding sources directly to the configuration
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Below we describe how add overriding sources directly to the configuration
Below we describe how to add overriding sources directly to the configuration


Below we describe how add overriding sources directly to the configuration
file, but it is also possible to do through the command line interface with the
[`sysand add`](../commands/add.md) command.
Copy link
Collaborator

@andrius-puksta-sensmetry andrius-puksta-sensmetry Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
[`sysand add`](../commands/add.md) command.
[`sysand add`](../commands/add.md) command by using one of the `--as-*` flags.

file, but it is also possible to do through the command line interface with the
[`sysand add`](../commands/add.md) command.

## Local projects
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe mention the exact CLI flag to use for each type here?


## Local editable projects

Normally when you add a project as a usage, `sysand` will copy and install it,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Normally when you add a project as a usage, `sysand` will copy and install it,
Normally when you add a project as a usage, Sysand will copy and install it,

sysand should refer to the CLI specifically, whereas Sysand refers to the product in general

## Local editable projects

Normally when you add a project as a usage, `sysand` will copy and install it,
so any changes made to the project after will not affect the installed project.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
so any changes made to the project after will not affect the installed project.
so any changes made to the original project afterwards will not affect the installed project.

the following entry to your `sysand.toml`.

```toml
[[project]]
Copy link
Collaborator

@andrius-puksta-sensmetry andrius-puksta-sensmetry Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe rename this to [[source]] to give us space to put the current project metadata here (if we ever decide to do this)?

Comment on lines +9 to +10
#[proc_macro_derive(ProjectRead)]
pub fn project_read_derive(input: TokenStream) -> TokenStream {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a doc comment describing what this derive generates (i.e. what the derived impl does given an example arg).

Comment on lines +201 to +202
#[proc_macro_derive(ProjectMut)]
pub fn project_mut_derive(input: TokenStream) -> TokenStream {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Description also needed here

Comment on lines +149 to +151
} else {
src_path.into()
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it assumed here that src_path is relative? What's stopping me from doing --as-path /path/to/project and breaking this assumption?

fn get_relative<P: Into<Utf8PathBuf>>(src_path: P, project_root: &Utf8Path) -> Result<Utf8PathBuf> {
let src_path = if wrapfs::current_dir()? != project_root {
let path = relativize(
&Utf8Path::new(&src_path.into()).canonicalize_utf8()?,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
&Utf8Path::new(&src_path.into()).canonicalize_utf8()?,
fn get_relative<P: Into<Utf8PathBuf> + AsRef<Utf8Path>>(
src_path: P,
project_root: &Utf8Path,
) -> Result<Utf8PathBuf> {
let src_path = if wrapfs::current_dir()? != project_root {
let path = relativize(&src_path.as_ref().canonicalize_utf8()?, project_root);

Comment on lines 28 to 42
pub fn command_add<S: AsRef<str>, Policy: HTTPAuthentication>(
iri: S,
versions_constraint: Option<String>,
no_lock: bool,
no_sync: bool,
resolution_opts: ResolutionOptions,
config: &Config,
source_opts: ProjectSourceOptions,
mut config: Config,
config_file: Option<String>,
no_config: bool,
current_project: Option<LocalSrcProject>,
client: reqwest_middleware::ClientWithMiddleware,
runtime: Arc<tokio::runtime::Runtime>,
auth_policy: Arc<Policy>,
) -> Result<()> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really getting out of hand, but fixing is out of scope here.


let config_path = config_file
.map(Utf8PathBuf::from)
.or((!no_config).then(|| project_root.join(CONFIG_FILE)));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this to where it's used.

Comment on lines +175 to +179
for r in root_iter {
if let Utf8Component::Normal(_) = r {
result.push("..");
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not always correct due to symlinks, unless both paths are already canonical.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will also return an empty path if path and base are the same.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And the paths will use platform-dependent separator: \ on Windows and / everywhere? else. Is config.toml intended to be portable?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants