use std::borrow::Cow;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Default)]
pub struct Loaded {
pub effective: Config,
pub user: Config,
pub project: Config,
pub user_path: Option<PathBuf>,
pub project_path: Option<PathBuf>,
}
impl Loaded {
#[must_use]
pub fn debt_ignore_sources(&self) -> Vec<(&str, &'static str)> {
let contains = |c: &Config, pattern: &str| {
c.debt
.ignore
.as_deref()
.is_some_and(|ps| ps.iter().any(|p| p == pattern))
};
self.effective
.debt
.ignore
.as_deref()
.unwrap_or_default()
.iter()
.map(|pattern| {
let layer = match (
contains(&self.project, pattern),
contains(&self.user, pattern),
) {
(true, true) => "project, user",
(true, false) => "project",
(false, true) => "user",
(false, false) => "unknown",
};
(pattern.as_str(), layer)
})
.collect()
}
#[must_use]
pub fn debt_ignore_reset_was_inert(&self) -> bool {
self.user.debt.ignore_reset == Some(true) && self.effective.debt.ignore_reset != Some(true)
}
#[must_use]
pub fn debt_ignore_discarded(&self) -> Vec<&str> {
if self.effective.debt.ignore_reset != Some(true) {
return Vec::new();
}
let kept = self.effective.debt.ignore.as_deref().unwrap_or_default();
self.user
.debt
.ignore
.as_deref()
.unwrap_or_default()
.iter()
.filter(|p| !kept.iter().any(|k| k == *p))
.map(String::as_str)
.collect()
}
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct Config {
pub models: ModelsConfig,
pub infer: InferConfig,
pub duplicates: DuplicatesConfig,
pub ingest: IngestConfig,
pub media: MediaConfig,
pub serve: ServeConfig,
pub debt: DebtConfig,
pub paths: PathsConfig,
pub telemetry: TelemetryConfig,
pub workspace: WorkspaceConfig,
pub workspaces: Vec<NamedWorkspace>,
pub standalone: WorkspaceConfig,
pub links: Vec<LinkDecl>,
pub pins: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct DebtConfig {
pub ignore: Option<Vec<String>>,
#[serde(alias = "ignore-reset")]
pub ignore_reset: Option<bool>,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct PathsConfig {
pub model_store: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct TelemetryConfig {
pub file: Option<String>,
pub rotation: Option<String>,
pub format: Option<String>,
}
impl TelemetryConfig {
fn overlaid_with(&self, over: &Self) -> Self {
Self {
file: over.file.clone().or_else(|| self.file.clone()),
rotation: over.rotation.clone().or_else(|| self.rotation.clone()),
format: over.format.clone().or_else(|| self.format.clone()),
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct WorkspaceConfig {
pub roots: Option<Vec<String>>,
pub repos: Option<Vec<String>>,
}
impl WorkspaceConfig {
fn is_empty(&self) -> bool {
self.roots.as_ref().is_none_or(Vec::is_empty)
&& self.repos.as_ref().is_none_or(Vec::is_empty)
}
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct NamedWorkspace {
pub name: String,
pub roots: Option<Vec<String>>,
pub repos: Option<Vec<String>>,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
pub struct LinkDecl {
pub to: String,
#[serde(default)]
pub from: Option<String>,
#[serde(default)]
pub kind: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct ModelsConfig {
pub embedding: Option<String>,
pub generative: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct IngestConfig {
pub prose: Option<bool>,
pub pdf: Option<bool>,
pub ocr: Option<bool>,
pub vision: Option<bool>,
pub audio: Option<bool>,
}
impl IngestConfig {
#[must_use]
pub fn resolve(&self) -> rto_graph::IngestConfig {
let default = rto_graph::IngestConfig::default();
rto_graph::IngestConfig {
prose: self.prose.unwrap_or(default.prose),
pdf: self.pdf.unwrap_or(default.pdf),
ocr: self.ocr.unwrap_or(default.ocr),
vision: self.vision.unwrap_or(default.vision),
audio: self.audio.unwrap_or(default.audio),
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct MediaConfig {
pub gate: Option<bool>,
pub silence_rms: Option<f64>,
pub image_variance: Option<f64>,
}
impl MediaConfig {
#[must_use]
pub fn resolve(&self) -> rto_graph::GateThresholds {
if self.gate == Some(false) {
return rto_graph::GateThresholds::disabled();
}
let default = rto_graph::GateThresholds::default();
rto_graph::GateThresholds {
silence_rms: self.silence_rms.unwrap_or(default.silence_rms),
image_variance: self.image_variance.unwrap_or(default.image_variance),
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct ServeConfig {
pub addr: Option<String>,
pub models: Option<Vec<String>>,
pub tools: Option<bool>,
pub memory_budget_mb: Option<u64>,
pub tls_cert: Option<String>,
pub tls_key: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct InferConfig {
pub min_confidence: Option<f64>,
pub top_k: Option<usize>,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct DuplicatesConfig {
pub min_similarity: Option<f64>,
pub limit: Option<usize>,
}
#[cfg(any(feature = "explorer", feature = "serve"))]
pub fn debt_ignore_for(
ws: &rto_graph::Workspace,
project: Option<&str>,
) -> anyhow::Result<Vec<String>> {
let Some(root) = ws.project_root(project)? else {
return Ok(Vec::new());
};
let loaded = load(&root).map_err(|e| {
anyhow::anyhow!(
"reading the configuration of the repository at {}: {e}",
root.display()
)
})?;
Ok(loaded.effective.debt.ignore.unwrap_or_default())
}
fn merge_ignore(base: Option<&[String]>, over: &DebtConfig) -> Option<Vec<String>> {
if over.ignore_reset == Some(true) {
return over.ignore.clone();
}
match (base, over.ignore.as_deref()) {
(None, None) => None,
(Some(only), None) | (None, Some(only)) => Some(only.to_vec()),
(Some(base), Some(over)) => {
let mut merged: Vec<String> = base.to_vec();
for pattern in over {
if !merged.iter().any(|p| p == pattern) {
merged.push(pattern.clone());
}
}
Some(merged)
}
}
}
impl Config {
fn overlaid_with(&self, over: &Config) -> Config {
Config {
models: ModelsConfig {
embedding: over
.models
.embedding
.clone()
.or(self.models.embedding.clone()),
generative: over
.models
.generative
.clone()
.or(self.models.generative.clone()),
},
infer: InferConfig {
min_confidence: over.infer.min_confidence.or(self.infer.min_confidence),
top_k: over.infer.top_k.or(self.infer.top_k),
},
duplicates: DuplicatesConfig {
min_similarity: over
.duplicates
.min_similarity
.or(self.duplicates.min_similarity),
limit: over.duplicates.limit.or(self.duplicates.limit),
},
ingest: IngestConfig {
prose: over.ingest.prose.or(self.ingest.prose),
pdf: over.ingest.pdf.or(self.ingest.pdf),
ocr: over.ingest.ocr.or(self.ingest.ocr),
vision: over.ingest.vision.or(self.ingest.vision),
audio: over.ingest.audio.or(self.ingest.audio),
},
media: MediaConfig {
gate: over.media.gate.or(self.media.gate),
silence_rms: over.media.silence_rms.or(self.media.silence_rms),
image_variance: over.media.image_variance.or(self.media.image_variance),
},
serve: ServeConfig {
addr: over.serve.addr.clone().or(self.serve.addr.clone()),
models: over.serve.models.clone().or(self.serve.models.clone()),
tools: over.serve.tools.or(self.serve.tools),
memory_budget_mb: over.serve.memory_budget_mb.or(self.serve.memory_budget_mb),
tls_cert: over.serve.tls_cert.clone().or(self.serve.tls_cert.clone()),
tls_key: over.serve.tls_key.clone().or(self.serve.tls_key.clone()),
},
debt: DebtConfig {
ignore: merge_ignore(self.debt.ignore.as_deref(), &over.debt),
ignore_reset: over.debt.ignore_reset,
},
paths: PathsConfig {
model_store: over
.paths
.model_store
.clone()
.or(self.paths.model_store.clone()),
},
telemetry: self.telemetry.overlaid_with(&over.telemetry),
workspace: WorkspaceConfig {
roots: over
.workspace
.roots
.clone()
.or(self.workspace.roots.clone()),
repos: over
.workspace
.repos
.clone()
.or(self.workspace.repos.clone()),
},
workspaces: if over.workspaces.is_empty() {
self.workspaces.clone()
} else {
over.workspaces.clone()
},
standalone: WorkspaceConfig {
roots: over
.standalone
.roots
.clone()
.or(self.standalone.roots.clone()),
repos: over
.standalone
.repos
.clone()
.or(self.standalone.repos.clone()),
},
links: if over.links.is_empty() {
self.links.clone()
} else {
over.links.clone()
},
pins: {
let mut m = self.pins.clone();
m.extend(over.pins.clone());
m
},
}
}
pub fn resolved_workspaces(&self) -> anyhow::Result<Vec<rto_graph::ResolvedWorkspace>> {
use std::collections::HashSet;
let mut out: Vec<rto_graph::ResolvedWorkspace> = Vec::new();
let mut used: HashSet<String> = HashSet::new();
if !self.workspace.is_empty() {
used.insert("default".to_owned());
out.push(rto_graph::ResolvedWorkspace {
name: "default".to_owned(),
roots: expand_tilde_all(self.workspace.roots.clone().unwrap_or_default()),
repos: expand_tilde_all(self.workspace.repos.clone().unwrap_or_default()),
linked: true,
});
}
for nw in &self.workspaces {
if !used.insert(nw.name.clone()) {
anyhow::bail!(
"duplicate workspace name `{}` — `[[workspaces]]` names (and the legacy \
`[workspace]`, which folds in as `default`) must each be unique",
nw.name
);
}
out.push(rto_graph::ResolvedWorkspace {
name: nw.name.clone(),
roots: expand_tilde_all(nw.roots.clone().unwrap_or_default()),
repos: expand_tilde_all(nw.repos.clone().unwrap_or_default()),
linked: true,
});
}
for repo in self.standalone_repo_paths()? {
let base = repo
.file_name()
.map_or_else(|| "repo".to_owned(), |s| s.to_string_lossy().into_owned());
let name = dedupe_workspace_name(&mut used, base);
out.push(rto_graph::ResolvedWorkspace {
name,
roots: Vec::new(),
repos: vec![repo.to_string_lossy().into_owned()],
linked: false,
});
}
Ok(out)
}
fn standalone_repo_paths(&self) -> anyhow::Result<Vec<PathBuf>> {
use std::collections::HashSet;
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut out: Vec<PathBuf> = Vec::new();
for root in self.standalone.roots.iter().flatten() {
for repo in rto_graph::discover_repos_under(&expand_tilde(root))? {
if seen.insert(repo.clone()) {
out.push(repo);
}
}
}
for repo in self.standalone.repos.iter().flatten() {
let p = expand_tilde(repo).into_owned();
if seen.insert(p.clone()) {
out.push(p);
}
}
Ok(out)
}
}
fn expand_tilde_all(paths: Vec<String>) -> Vec<String> {
paths
.into_iter()
.map(|p| expand_tilde(&p).to_string_lossy().into_owned())
.collect()
}
pub(crate) fn expand_tilde(path: &str) -> Cow<'_, Path> {
if path != "~" && !path.starts_with("~/") {
return Cow::Borrowed(Path::new(path));
}
let home = home_dir();
Cow::Owned(expand_tilde_with(
path,
home.as_deref().map(std::path::Path::as_os_str),
))
}
pub(crate) fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
fn expand_tilde_with(path: &str, home: Option<&std::ffi::OsStr>) -> PathBuf {
if path == "~"
&& let Some(h) = home
{
return PathBuf::from(h);
}
if let Some(rest) = path.strip_prefix("~/")
&& let Some(h) = home
{
return Path::new(h).join(rest);
}
PathBuf::from(path)
}
fn dedupe_workspace_name(used: &mut std::collections::HashSet<String>, base: String) -> String {
if used.insert(base.clone()) {
return base;
}
let mut n = 2u32;
loop {
let candidate = format!("{base}-{n}");
if used.insert(candidate.clone()) {
return candidate;
}
n += 1;
}
}
pub fn load(cwd: &Path) -> anyhow::Result<Loaded> {
let user_path = user_config_path().filter(|p| p.is_file());
let project_path = find_project_config(cwd);
load_from(user_path, project_path)
}
fn load_from(user_path: Option<PathBuf>, project_path: Option<PathBuf>) -> anyhow::Result<Loaded> {
let user = read_config(user_path.as_deref())?;
let project = read_config(project_path.as_deref())?;
let effective = user.overlaid_with(&project);
Ok(Loaded {
effective,
user,
project,
user_path,
project_path,
})
}
fn read_config(path: Option<&Path>) -> anyhow::Result<Config> {
let Some(path) = path else {
return Ok(Config::default());
};
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("reading config {}: {e}", path.display()))?;
toml::from_str(&text).map_err(|e| anyhow::anyhow!("parsing config {}: {e}", path.display()))
}
pub(crate) fn roteiro_home() -> Option<PathBuf> {
if let Some(home) = std::env::var_os("ROTEIRO_HOME") {
return Some(PathBuf::from(home));
}
Some(home_dir()?.join(".roteiro"))
}
pub(crate) fn default_log_path() -> Option<PathBuf> {
Some(roteiro_home()?.join("logs").join("roteiro.log"))
}
fn user_config_path() -> Option<PathBuf> {
Some(roteiro_home()?.join("config.toml"))
}
fn find_project_config(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(d) = dir {
if d.join(".git").exists() {
let candidate = d.join("roteiro.toml");
return candidate.is_file().then_some(candidate);
}
dir = d.parent();
}
None
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use super::{
Config, NamedWorkspace, WorkspaceConfig, expand_tilde_with, find_project_config, load_from,
};
#[test]
fn project_config_is_repo_root_bounded() {
let root = std::env::temp_dir().join(format!("roteiro-disc-{}", std::process::id()));
std::fs::remove_dir_all(&root).ok();
let repo = root.join("repo");
let sub = repo.join("crate").join("src");
std::fs::create_dir_all(&sub).expect("mkdir");
std::fs::create_dir_all(repo.join(".git")).expect("mkdir .git");
std::fs::write(root.join("roteiro.toml"), "[infer]\ntop_k = 1\n").expect("write outside");
assert_eq!(
find_project_config(&sub),
None,
"no repo-root config → None, never the parent-dir one"
);
let at_root = repo.join("roteiro.toml");
std::fs::write(&at_root, "[infer]\ntop_k = 2\n").expect("write root");
assert_eq!(find_project_config(&sub), Some(at_root));
assert_eq!(find_project_config(&root), None);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn config_layering_precedence_and_errors() {
let dir = std::env::temp_dir().join(format!("roteiro-cfg-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let user = dir.join("config.toml");
let project = dir.join("roteiro.toml");
let loaded = load_from(None, None).expect("load");
assert_eq!(loaded.effective, Config::default());
assert!(loaded.project_path.is_none());
std::fs::write(
&user,
"[infer]\nmin_confidence = 0.3\ntop_k = 9\n[models]\nembedding = \"bge-base-en-v1.5\"\n",
)
.expect("write user");
std::fs::write(&project, "[infer]\nmin_confidence = 0.7\n").expect("write project");
let loaded = load_from(Some(user.clone()), Some(project.clone())).expect("load");
assert_eq!(loaded.effective.infer.min_confidence, Some(0.7));
assert_eq!(loaded.effective.infer.top_k, Some(9));
assert_eq!(
loaded.effective.models.embedding.as_deref(),
Some("bge-base-en-v1.5")
);
assert_eq!(loaded.user.infer.min_confidence, Some(0.3));
assert_eq!(loaded.project.infer.min_confidence, Some(0.7));
std::fs::write(&project, "[future]\nwhatever = true\n[infer]\ntop_k = 2\n").expect("write");
let loaded = load_from(None, Some(project.clone())).expect("unknown keys ignored");
assert_eq!(loaded.effective.infer.top_k, Some(2));
std::fs::write(&project, "[infer]\nmin_confidence = = =\n").expect("write");
assert!(
load_from(None, Some(project.clone())).is_err(),
"malformed TOML must error"
);
std::fs::write(&user, "[pins]\napp = \"v{tag}\"\nother = \"user-{tag}\"\n").expect("write");
std::fs::write(&project, "[pins]\napp = \"release-{tag}\"\n").expect("write");
let loaded = load_from(Some(user), Some(project)).expect("load");
assert_eq!(
loaded.effective.pins.get("app").map(String::as_str),
Some("release-{tag}")
);
assert_eq!(
loaded.effective.pins.get("other").map(String::as_str),
Some("user-{tag}")
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn debt_ignore_merges_across_layers_instead_of_replacing() {
let dir = std::env::temp_dir().join(format!("roteiro-cfg-debt-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let user = dir.join("config.toml");
let project = dir.join("roteiro.toml");
std::fs::write(&user, "[debt]\nignore = [\"vendor/**\", \"target/**\"]\n").expect("user");
std::fs::write(&project, "[debt]\nignore = [\"thirdparty/**\"]\n").expect("project");
let loaded = load_from(Some(user.clone()), Some(project.clone())).expect("load");
assert_eq!(
loaded.effective.debt.ignore.as_deref(),
Some(
[
"vendor/**".to_owned(),
"target/**".to_owned(),
"thirdparty/**".to_owned()
]
.as_slice()
),
);
assert_eq!(
loaded.debt_ignore_sources(),
vec![
("vendor/**", "user"),
("target/**", "user"),
("thirdparty/**", "project"),
]
);
assert!(loaded.debt_ignore_discarded().is_empty(), "nothing dropped");
std::fs::write(
&project,
"[debt]\nignore = [\"vendor/**\", \"thirdparty/**\"]\n",
)
.expect("project");
let loaded = load_from(Some(user.clone()), Some(project.clone())).expect("load");
assert_eq!(
loaded.effective.debt.ignore.as_deref(),
Some(
[
"vendor/**".to_owned(),
"target/**".to_owned(),
"thirdparty/**".to_owned()
]
.as_slice()
),
"a duplicate pattern is not listed twice"
);
assert_eq!(
loaded.debt_ignore_sources()[0],
("vendor/**", "project, user")
);
let only_user = load_from(Some(user.clone()), None).expect("load");
assert_eq!(
only_user.effective.debt.ignore.as_deref(),
Some(["vendor/**".to_owned(), "target/**".to_owned()].as_slice())
);
let only_project = load_from(None, Some(project.clone())).expect("load");
assert_eq!(
only_project.effective.debt.ignore.as_deref(),
Some(["vendor/**".to_owned(), "thirdparty/**".to_owned()].as_slice())
);
assert!(
load_from(None, None)
.expect("load")
.effective
.debt
.ignore
.is_none()
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn debt_ignore_reset_drops_the_inherited_list_visibly() {
let dir = std::env::temp_dir().join(format!("roteiro-cfg-reset-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let user = dir.join("config.toml");
let project = dir.join("roteiro.toml");
std::fs::write(&user, "[debt]\nignore = [\"vendor/**\", \"target/**\"]\n").expect("user");
std::fs::write(
&project,
"[debt]\nignore_reset = true\nignore = [\"thirdparty/**\"]\n",
)
.expect("project");
let loaded = load_from(Some(user.clone()), Some(project.clone())).expect("load");
assert_eq!(
loaded.effective.debt.ignore.as_deref(),
Some(["thirdparty/**".to_owned()].as_slice())
);
assert_eq!(
loaded.debt_ignore_discarded(),
vec!["vendor/**", "target/**"]
);
std::fs::write(
&project,
"[debt]\nignore-reset = true\nignore = [\"thirdparty/**\"]\n",
)
.expect("project");
let kebab = load_from(Some(user.clone()), Some(project.clone())).expect("load");
assert_eq!(kebab.effective.debt.ignore_reset, Some(true));
assert_eq!(
kebab.effective.debt.ignore.as_deref(),
Some(["thirdparty/**".to_owned()].as_slice())
);
std::fs::write(&project, "[debt]\nignore_reset = true\n").expect("project");
let bare = load_from(Some(user.clone()), Some(project.clone())).expect("load");
assert!(bare.effective.debt.ignore.is_none(), "inherits nothing");
assert_eq!(bare.debt_ignore_discarded(), vec!["vendor/**", "target/**"]);
std::fs::write(
&project,
"[debt]\nignore_reset = false\nignore = [\"thirdparty/**\"]\n",
)
.expect("project");
let off = load_from(Some(user), Some(project)).expect("load");
assert_eq!(
off.effective.debt.ignore.as_deref(),
Some(
[
"vendor/**".to_owned(),
"target/**".to_owned(),
"thirdparty/**".to_owned()
]
.as_slice()
)
);
assert!(off.debt_ignore_discarded().is_empty());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_inert_user_layer_reset_does_not_claim_a_reset_that_never_happened() {
let dir = std::env::temp_dir().join(format!("roteiro-cfg-inert-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let user = dir.join("config.toml");
let project = dir.join("roteiro.toml");
std::fs::write(
&user,
"[debt]\nignore_reset = true\nignore = [\"vendor/**\", \"target/**\"]\n",
)
.expect("user");
std::fs::write(&project, "[debt]\nignore = [\"thirdparty/**\"]\n").expect("project");
let loaded = load_from(Some(user.clone()), Some(project.clone())).expect("load");
assert_ne!(
loaded.effective.debt.ignore_reset,
Some(true),
"a user-layer reset governs nothing, so it must not appear as one in \
the effective config — that is the claim `roteiro config` prints"
);
assert!(
loaded.debt_ignore_reset_was_inert(),
"the user asked for a reset that did nothing; that must be reportable"
);
assert!(
loaded.debt_ignore_discarded().is_empty(),
"no pattern was dropped, so none may be reported as dropped: {:?}",
loaded.debt_ignore_discarded()
);
assert_eq!(
loaded.effective.debt.ignore.as_deref(),
Some(
[
"vendor/**".to_owned(),
"target/**".to_owned(),
"thirdparty/**".to_owned()
]
.as_slice()
),
"the merge is unaffected — this was only ever a reporting defect"
);
std::fs::write(
&project,
"[debt]\nignore_reset = true\nignore = [\"thirdparty/**\"]\n",
)
.expect("project");
let both = load_from(Some(user.clone()), Some(project)).expect("load");
assert_eq!(both.effective.debt.ignore_reset, Some(true));
assert_eq!(both.debt_ignore_discarded(), vec!["vendor/**", "target/**"]);
assert!(
!both.debt_ignore_reset_was_inert(),
"a reset really happened, so `roteiro config` reports the drop rather \
than a second note saying the user's own flag was individually inert"
);
let alone = load_from(Some(user), None).expect("load");
assert_ne!(alone.effective.debt.ignore_reset, Some(true));
assert!(alone.debt_ignore_reset_was_inert());
assert_eq!(
alone.effective.debt.ignore.as_deref(),
Some(["vendor/**".to_owned(), "target/**".to_owned()].as_slice()),
"and it resets none of the user's own patterns"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn other_list_keys_still_replace_rather_than_merge() {
let dir = std::env::temp_dir().join(format!("roteiro-cfg-lists-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let user = dir.join("config.toml");
let project = dir.join("roteiro.toml");
std::fs::write(
&user,
"[workspace]\nroots = [\"/u/one\"]\n[serve]\nmodels = [\"a\"]\n",
)
.expect("user");
std::fs::write(
&project,
"[workspace]\nroots = [\"/p/two\"]\n[serve]\nmodels = [\"b\"]\n",
)
.expect("project");
let loaded = load_from(Some(user), Some(project)).expect("load");
assert_eq!(
loaded.effective.workspace.roots.as_deref(),
Some(["/p/two".to_owned()].as_slice()),
"discovery roots replace"
);
assert_eq!(
loaded.effective.serve.models.as_deref(),
Some(["b".to_owned()].as_slice()),
"model selection replaces"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn telemetry_block_parses_and_overlays_per_field() {
let none: Config = toml::from_str("[infer]\ntop_k = 1\n").expect("parse");
assert_eq!(none.telemetry, super::TelemetryConfig::default());
let user: Config = toml::from_str(
"[telemetry]\nfile = \"~/.roteiro/logs/roteiro.log\"\nrotation = \"daily\"\nformat = \"otel\"\n",
)
.expect("parse user");
assert_eq!(
user.telemetry.file.as_deref(),
Some("~/.roteiro/logs/roteiro.log")
);
assert_eq!(user.telemetry.rotation.as_deref(), Some("daily"));
let project: Config =
toml::from_str("[telemetry]\nrotation = \"hourly\"\n").expect("parse project");
let merged = user.overlaid_with(&project);
assert_eq!(merged.telemetry.rotation.as_deref(), Some("hourly"));
assert_eq!(
merged.telemetry.file.as_deref(),
Some("~/.roteiro/logs/roteiro.log")
);
assert_eq!(merged.telemetry.format.as_deref(), Some("otel"));
}
#[test]
fn legacy_workspace_only_resolves_to_one_default_linked_group() {
let cfg = Config {
workspace: WorkspaceConfig {
roots: Some(vec!["/code".to_owned()]),
repos: Some(vec!["/code/extra".to_owned()]),
},
..Default::default()
};
let resolved = cfg.resolved_workspaces().expect("resolve");
assert_eq!(resolved.len(), 1);
let d = &resolved[0];
assert_eq!(d.name, "default");
assert!(d.linked);
assert_eq!(d.roots, vec!["/code".to_owned()]);
assert_eq!(d.repos, vec!["/code/extra".to_owned()]);
assert!(
Config::default()
.resolved_workspaces()
.expect("resolve default")
.is_empty()
);
let user: Config = toml::from_str("[workspace]\nroots = [\"/code\"]\n").expect("parse");
let merged = user.overlaid_with(&Config::default());
assert_eq!(
merged.workspace.roots.as_deref(),
Some(&["/code".to_owned()][..])
);
assert!(merged.workspaces.is_empty());
assert!(merged.standalone.is_empty());
}
#[test]
fn named_workspaces_and_standalone_partition_correctly() {
let base = std::env::temp_dir().join(format!("roteiro-rw-{}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
for name in ["docs", "tools"] {
std::fs::create_dir_all(base.join("solo").join(name).join(".git")).expect("mkrepo");
}
let cfg = Config {
workspace: WorkspaceConfig {
roots: Some(vec!["/legacy".to_owned()]),
repos: None,
},
workspaces: vec![
NamedWorkspace {
name: "api".to_owned(),
roots: Some(vec!["/api".to_owned()]),
repos: None,
},
NamedWorkspace {
name: "web".to_owned(),
roots: None,
repos: Some(vec!["/web/app".to_owned()]),
},
],
standalone: WorkspaceConfig {
roots: Some(vec![base.join("solo").to_string_lossy().into_owned()]),
repos: None,
},
..Default::default()
};
let resolved = cfg.resolved_workspaces().expect("resolve");
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["default", "api", "web", "docs", "tools"]);
let by_name = |n: &str| resolved.iter().find(|r| r.name == n).expect("group");
assert!(by_name("default").linked);
assert!(by_name("api").linked);
assert!(by_name("web").linked);
let docs = by_name("docs");
assert!(!docs.linked, "standalone repos are unlinked");
assert!(docs.roots.is_empty());
assert_eq!(docs.repos.len(), 1, "a standalone group is a singleton");
assert!(docs.repos[0].ends_with("docs"));
assert!(!by_name("tools").linked);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn standalone_names_dedupe_against_linked_names() {
let base = std::env::temp_dir().join(format!("roteiro-rw-dedupe-{}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
let repo = base.join("default");
std::fs::create_dir_all(repo.join(".git")).expect("mkrepo");
let cfg = Config {
workspace: WorkspaceConfig {
roots: Some(vec!["/legacy".to_owned()]),
repos: None,
},
standalone: WorkspaceConfig {
roots: None,
repos: Some(vec![repo.to_string_lossy().into_owned()]),
},
..Default::default()
};
let resolved = cfg.resolved_workspaces().expect("resolve");
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["default", "default-2"]);
assert!(
resolved
.iter()
.find(|r| r.name == "default")
.unwrap()
.linked
);
assert!(
!resolved
.iter()
.find(|r| r.name == "default-2")
.unwrap()
.linked
);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn multi_workspace_fields_parse_and_overlay() {
let user: Config = toml::from_str(
"[[workspaces]]\nname = \"api\"\nroots = [\"/api\"]\n\
[standalone]\nrepos = [\"/solo/x\"]\n",
)
.expect("parse user");
assert_eq!(user.workspaces.len(), 1);
assert_eq!(user.workspaces[0].name, "api");
assert_eq!(
user.standalone.repos.as_deref(),
Some(&["/solo/x".to_owned()][..])
);
let project: Config =
toml::from_str("[[workspaces]]\nname = \"web\"\n").expect("parse project");
let merged = user.overlaid_with(&project);
assert_eq!(merged.workspaces.len(), 1);
assert_eq!(merged.workspaces[0].name, "web");
assert_eq!(
merged.standalone.repos.as_deref(),
Some(&["/solo/x".to_owned()][..])
);
}
#[test]
fn duplicate_linked_workspace_names_are_a_config_error() {
let cfg = Config {
workspaces: vec![
NamedWorkspace {
name: "api".to_owned(),
roots: Some(vec!["/a".to_owned()]),
repos: None,
},
NamedWorkspace {
name: "api".to_owned(),
roots: Some(vec!["/b".to_owned()]),
repos: None,
},
],
..Default::default()
};
let err = cfg
.resolved_workspaces()
.expect_err("duplicate [[workspaces]] name must error")
.to_string();
assert!(
err.contains("duplicate workspace name") && err.contains("api"),
"{err}"
);
let cfg = Config {
workspace: WorkspaceConfig {
roots: Some(vec!["/legacy".to_owned()]),
repos: None,
},
workspaces: vec![NamedWorkspace {
name: "default".to_owned(),
roots: Some(vec!["/x".to_owned()]),
repos: None,
}],
..Default::default()
};
let err = cfg
.resolved_workspaces()
.expect_err("[[workspaces]] named `default` must collide with the legacy fold-in")
.to_string();
assert!(err.contains("default"), "{err}");
}
#[test]
fn standalone_root_yields_one_single_repo_group_per_repo() {
let base = std::env::temp_dir().join(format!("roteiro-rw-solo-{}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
for name in ["svc-a", "svc-b"] {
std::fs::create_dir_all(base.join("pool").join(name).join(".git")).expect("mkrepo");
}
let cfg = Config {
standalone: WorkspaceConfig {
roots: Some(vec![base.join("pool").to_string_lossy().into_owned()]),
repos: None,
},
..Default::default()
};
let resolved = cfg.resolved_workspaces().expect("resolve");
assert_eq!(resolved.len(), 2, "one group per repo: {resolved:?}");
for rw in &resolved {
assert!(!rw.linked, "standalone repos are unlinked: {rw:?}");
assert_eq!(rw.repos.len(), 1, "each is a singleton: {rw:?}");
assert!(rw.roots.is_empty(), "expanded, not a roots scan: {rw:?}");
}
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert_eq!(
names,
vec!["svc-a", "svc-b"],
"named after repo dirs, sorted"
);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn expand_tilde_handles_bare_and_prefixed_and_passes_others_through() {
let home = std::ffi::OsString::from("/home/alice");
let h = Some(home.as_os_str());
assert_eq!(expand_tilde_with("~", h), PathBuf::from("/home/alice"));
assert_eq!(
expand_tilde_with("~/foo/bar", h),
PathBuf::from("/home/alice/foo/bar")
);
assert_eq!(
expand_tilde_with("/abs/path", h),
PathBuf::from("/abs/path")
);
assert_eq!(expand_tilde_with("rel/path", h), PathBuf::from("rel/path"));
assert_eq!(expand_tilde_with("~bob/x", h), PathBuf::from("~bob/x"));
assert_eq!(expand_tilde_with("~/foo", None), PathBuf::from("~/foo"));
assert_eq!(expand_tilde_with("~", None), PathBuf::from("~"));
}
#[test]
fn resolved_workspaces_expand_leading_tilde_in_all_path_inputs() {
let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))
else {
return;
};
let home = Path::new(&home);
let joined = |rest: &str| home.join(rest).to_string_lossy().into_owned();
let cfg = Config {
workspace: WorkspaceConfig {
roots: Some(vec!["~/legacy/root".to_owned()]),
repos: Some(vec!["~/legacy/repo".to_owned()]),
},
workspaces: vec![NamedWorkspace {
name: "api".to_owned(),
roots: Some(vec!["~/api/root".to_owned()]),
repos: Some(vec!["~/api/repo".to_owned()]),
}],
standalone: WorkspaceConfig {
roots: None,
repos: Some(vec!["~/solo/repo".to_owned(), "/abs/solo".to_owned()]),
},
..Default::default()
};
let resolved = cfg.resolved_workspaces().expect("resolve");
let by_name = |n: &str| resolved.iter().find(|r| r.name == n).expect("group");
let d = by_name("default");
assert_eq!(d.roots, vec![joined("legacy/root")]);
assert_eq!(d.repos, vec![joined("legacy/repo")]);
let api = by_name("api");
assert_eq!(api.roots, vec![joined("api/root")]);
assert_eq!(api.repos, vec![joined("api/repo")]);
assert_eq!(by_name("repo").repos, vec![joined("solo/repo")]);
assert_eq!(by_name("solo").repos, vec!["/abs/solo".to_owned()]);
for rw in &resolved {
for p in rw.roots.iter().chain(&rw.repos) {
assert!(!p.starts_with('~'), "unexpanded tilde survived: {p}");
}
}
}
}