use serde::{Deserialize, Serialize};
use std::path::{Component, Path, PathBuf};
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
pub struct WorkspaceConfig {
#[serde(default)]
pub members: Vec<String>,
#[serde(default)]
pub inherit: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct WorkspaceContext {
pub root_config: PathBuf,
#[allow(dead_code)] pub member_config: Option<PathBuf>,
pub workspace: WorkspaceConfig,
pub current_member: Option<String>,
}
pub fn find_workspace_root(start: &Path) -> Option<WorkspaceContext> {
let mut current = start.to_path_buf();
loop {
let config_path = current.join("jarvy.toml");
if config_path.exists() {
if let Ok(content) = std::fs::read_to_string(&config_path) {
if let Ok(parsed) = toml::from_str::<toml::Value>(&content) {
if let Some(ws) = parsed.get("workspace") {
if let Ok(workspace) = toml::Value::try_into::<WorkspaceConfig>(ws.clone())
{
let current_member = determine_member(start, ¤t, &workspace);
let member_config = current_member
.as_ref()
.map(|m| current.join(m).join("jarvy.toml"))
.filter(|p| p.exists());
return Some(WorkspaceContext {
root_config: config_path,
member_config,
workspace,
current_member,
});
}
}
}
}
}
if !current.pop() {
break;
}
}
None
}
fn determine_member(target: &Path, root: &Path, workspace: &WorkspaceConfig) -> Option<String> {
let relative = target.strip_prefix(root).ok()?;
let target_components: Vec<Component<'_>> = relative.components().collect();
for member in &workspace.members {
let member_components: Vec<Component<'_>> = Path::new(member).components().collect();
if member_components.is_empty() {
continue;
}
if target_components.len() < member_components.len() {
continue;
}
if target_components[..member_components.len()] == member_components[..] {
return Some(member.clone());
}
}
None
}
pub fn merge_configs(root: &toml::Value, member: &toml::Value, inherit: &[String]) -> toml::Value {
let mut merged = member.clone();
let Some(root_table) = root.as_table() else {
return merged;
};
let Some(merged_table) = merged.as_table_mut() else {
return merged;
};
for section in inherit {
if !merged_table.contains_key(section) {
if let Some(root_val) = root_table.get(section) {
merged_table.insert(section.clone(), root_val.clone());
}
} else if section == "provisioner" {
if let (Some(root_tools), Some(merged_tools)) = (
root_table.get(section).and_then(|v| v.as_table()),
merged_table.get_mut(section).and_then(|v| v.as_table_mut()),
) {
for (tool, version) in root_tools {
if !merged_tools.contains_key(tool) {
merged_tools.insert(tool.clone(), version.clone());
}
}
}
}
}
merged
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_merge_configs_inherits_missing_sections() {
let root: toml::Value = toml::from_str(
r#"
[provisioner]
git = "latest"
node = "20"
[env.vars]
FOO = "bar"
"#,
)
.unwrap();
let member: toml::Value = toml::from_str(
r#"
[provisioner]
python = "3.12"
"#,
)
.unwrap();
let merged = merge_configs(&root, &member, &["provisioner".into(), "env".into()]);
let table = merged.as_table().unwrap();
let prov = table.get("provisioner").unwrap().as_table().unwrap();
assert!(prov.contains_key("python"));
assert!(prov.contains_key("git"));
assert!(prov.contains_key("node"));
assert!(table.contains_key("env"));
}
#[test]
fn determine_member_does_not_match_prefix_collision() {
let workspace = WorkspaceConfig {
members: vec!["app".to_string()],
inherit: vec![],
};
let root = Path::new("/repo");
let target = Path::new("/repo/apple/main.rs");
assert_eq!(determine_member(target, root, &workspace), None);
}
#[test]
fn determine_member_matches_exact_first_component() {
let workspace = WorkspaceConfig {
members: vec!["app".to_string(), "service-a".to_string()],
inherit: vec![],
};
let root = Path::new("/repo");
assert_eq!(
determine_member(Path::new("/repo/app"), root, &workspace),
Some("app".to_string())
);
assert_eq!(
determine_member(Path::new("/repo/app/src/main.rs"), root, &workspace),
Some("app".to_string())
);
assert_eq!(
determine_member(Path::new("/repo/service-a/Cargo.toml"), root, &workspace),
Some("service-a".to_string())
);
}
#[test]
fn determine_member_handles_multi_segment_member_path() {
let workspace = WorkspaceConfig {
members: vec!["packages/web".to_string()],
inherit: vec![],
};
let root = Path::new("/repo");
assert_eq!(
determine_member(Path::new("/repo/packages/web/index.html"), root, &workspace),
Some("packages/web".to_string())
);
assert_eq!(
determine_member(Path::new("/repo/packages/webex/x"), root, &workspace),
None
);
}
#[test]
fn find_workspace_root_returns_none_outside_workspace() {
let tmp = tempfile::TempDir::new().unwrap();
assert!(find_workspace_root(tmp.path()).is_none());
}
#[test]
fn find_workspace_root_finds_root_from_member_dir() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let app_dir = root.join("app");
std::fs::create_dir_all(&app_dir).unwrap();
std::fs::write(
root.join("jarvy.toml"),
r#"
[workspace]
members = ["app"]
inherit = ["provisioner"]
[provisioner]
git = "latest"
"#,
)
.unwrap();
let ctx = find_workspace_root(&app_dir).expect("workspace should be found");
assert_eq!(ctx.current_member.as_deref(), Some("app"));
assert_eq!(ctx.workspace.members, vec!["app".to_string()]);
assert_eq!(ctx.root_config, root.join("jarvy.toml"));
}
#[test]
fn merge_configs_inherits_explicit_section() {
let root: toml::Value = toml::from_str(
r#"
[drift]
enabled = true
version_policy = "minor"
"#,
)
.unwrap();
let member: toml::Value = toml::from_str(
r#"
[provisioner]
git = "latest"
"#,
)
.unwrap();
let merged = merge_configs(&root, &member, &["drift".into()]);
let table = merged.as_table().unwrap();
assert!(table.contains_key("drift"));
let drift = table.get("drift").unwrap().as_table().unwrap();
assert_eq!(drift.get("enabled").unwrap().as_bool(), Some(true));
}
#[test]
fn test_member_overrides_root() {
let root: toml::Value = toml::from_str(
r#"
[provisioner]
node = "18"
"#,
)
.unwrap();
let member: toml::Value = toml::from_str(
r#"
[provisioner]
node = "20"
"#,
)
.unwrap();
let merged = merge_configs(&root, &member, &["provisioner".into()]);
let prov = merged
.as_table()
.unwrap()
.get("provisioner")
.unwrap()
.as_table()
.unwrap();
assert_eq!(prov.get("node").unwrap().as_str().unwrap(), "20");
}
}