#![deny(warnings)]
use std::path::{Path, PathBuf};
use std::time::Instant;
use serde::{Deserialize, Serialize};
use crate::error::TuiError;
use crate::models::workspace::{ResolvedWorkspace, WorkspaceEntry, WorkspaceStatus};
#[derive(Debug, Deserialize, Serialize)]
struct RegistryFile {
#[serde(default, rename = "workspace")]
workspaces: Vec<WorkspaceEntry>,
}
#[derive(Debug)]
pub enum LoadResult {
Loaded(WorkspaceRegistry),
NotFound(WorkspaceRegistry),
}
#[derive(Debug, Clone)]
pub struct WorkspaceRegistry {
pub entries: Vec<WorkspaceEntry>,
pub path: PathBuf,
pub last_loaded: Instant,
}
impl WorkspaceRegistry {
pub fn load(path: &Path) -> Result<LoadResult, TuiError> {
if !path.exists() {
let empty = WorkspaceRegistry {
entries: vec![],
path: path.to_path_buf(),
last_loaded: Instant::now(),
};
return Ok(LoadResult::NotFound(empty));
}
let text = std::fs::read_to_string(path).map_err(|e| TuiError::RegistryParse {
path: path.display().to_string(),
detail: e.to_string(),
})?;
let reg = Self::parse_text(&text, path)?;
Ok(LoadResult::Loaded(reg))
}
fn parse_text(text: &str, path: &Path) -> Result<WorkspaceRegistry, TuiError> {
let file: RegistryFile = toml::from_str(text).map_err(|e| TuiError::RegistryParse {
path: path.display().to_string(),
detail: e.to_string(),
})?;
Ok(WorkspaceRegistry {
entries: file.workspaces,
path: path.to_path_buf(),
last_loaded: Instant::now(),
})
}
pub fn resolve(&self) -> Vec<ResolvedWorkspace> {
self.entries
.iter()
.map(|entry| {
let expanded = Self::expand_env(&entry.path);
let expanded_path = Path::new(&expanded);
let name = entry.name.clone().unwrap_or_else(|| basename_of(&expanded));
let tags = entry.tags.clone().unwrap_or_default();
let team = entry.team.clone();
match expanded_path.canonicalize() {
Ok(canonical) => ResolvedWorkspace {
canonical_path: canonical,
name,
team,
tags,
status: WorkspaceStatus::Available,
},
Err(e) => ResolvedWorkspace {
canonical_path: expanded_path.to_path_buf(),
name,
team,
tags,
status: WorkspaceStatus::Unavailable(e.to_string()),
},
}
})
.collect()
}
pub fn expand_env(path: &str) -> String {
Self::expand_env_with(path, |name| std::env::var(name).ok())
}
pub fn expand_env_with<F>(path: &str, lookup: F) -> String
where
F: Fn(&str) -> Option<String>,
{
let bytes = path.as_bytes();
let mut out = String::with_capacity(path.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'$' {
out.push(bytes[i] as char);
i += 1;
continue;
}
i += 1;
if i >= bytes.len() {
out.push('$');
continue;
}
if bytes[i] == b'{' {
i += 1; let start = i;
while i < bytes.len() && bytes[i] != b'}' {
i += 1;
}
if i >= bytes.len() {
out.push('$');
out.push('{');
out.push_str(&path[start..i]);
continue;
}
let var_name = &path[start..i];
i += 1; match lookup(var_name) {
Some(val) => out.push_str(&val),
None => {
out.push('$');
out.push('{');
out.push_str(var_name);
out.push('}');
}
}
} else if bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' {
let start = i;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
let var_name = &path[start..i];
match lookup(var_name) {
Some(val) => out.push_str(&val),
None => {
out.push('$');
out.push_str(var_name);
}
}
} else {
out.push('$');
}
}
out
}
pub fn find_duplicates(&self) -> Vec<(String, Vec<usize>)> {
use std::collections::HashMap;
let mut map: HashMap<String, Vec<usize>> = HashMap::new();
for (idx, entry) in self.entries.iter().enumerate() {
let expanded = Self::expand_env(&entry.path);
let key = match Path::new(&expanded).canonicalize() {
Ok(p) => p.to_string_lossy().into_owned(),
Err(_) => expanded,
};
map.entry(key).or_default().push(idx);
}
let mut result: Vec<(String, Vec<usize>)> = map
.into_iter()
.filter(|(_, indices)| indices.len() > 1)
.collect();
result.sort_by(|a, b| a.0.cmp(&b.0));
result
}
pub fn validate(&self) -> Vec<TuiError> {
let mut errors: Vec<TuiError> = vec![];
for (idx, entry) in self.entries.iter().enumerate() {
if entry.path.trim().is_empty() {
let label = entry
.name
.clone()
.unwrap_or_else(|| format!("entry[{idx}]"));
errors.push(TuiError::RegistryFieldMissing {
entry: label,
field: "path".to_string(),
});
}
}
for (key, indices) in self.find_duplicates() {
let labels: Vec<String> = indices
.iter()
.map(|&i| {
self.entries[i]
.name
.clone()
.unwrap_or_else(|| format!("entry[{i}]"))
})
.collect();
errors.push(TuiError::RegistryDuplicate {
path: self.path.display().to_string(),
entries: format!("{} (path: {})", labels.join(", "), key),
});
}
errors
}
pub fn filter<'a>(
&'a self,
pattern: &str,
resolved: &'a [ResolvedWorkspace],
) -> Vec<&'a ResolvedWorkspace> {
resolved
.iter()
.filter(|ws| {
glob_match(pattern, &ws.name)
|| glob_match(pattern, &ws.canonical_path.to_string_lossy())
})
.collect()
}
pub fn reload(&mut self) -> Result<(), TuiError> {
let path = self.path.clone();
let text = std::fs::read_to_string(&path).map_err(|e| TuiError::RegistryParse {
path: path.display().to_string(),
detail: e.to_string(),
})?;
let new_reg = Self::parse_text(&text, &path)?;
self.entries = new_reg.entries;
self.last_loaded = Instant::now();
Ok(())
}
}
fn basename_of(path: &str) -> String {
Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path)
.to_string()
}
pub fn glob_match(pattern: &str, text: &str) -> bool {
glob_match_bytes(pattern.as_bytes(), text.as_bytes())
}
fn glob_match_bytes(pat: &[u8], text: &[u8]) -> bool {
match (pat.first(), text.first()) {
(None, None) => true,
(None, Some(_)) => false,
(Some(b'*'), _) => {
glob_match_bytes(&pat[1..], text)
|| (!text.is_empty() && glob_match_bytes(pat, &text[1..]))
}
(Some(b'?'), Some(_)) => glob_match_bytes(&pat[1..], &text[1..]),
(Some(b'?'), None) => false,
(Some(p), Some(t)) => p == t && glob_match_bytes(&pat[1..], &text[1..]),
(Some(_), None) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::io::{Seek, Write};
use tempfile::NamedTempFile;
fn write_toml(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f.flush().unwrap();
f
}
#[test]
fn load_missing_file_returns_not_found() {
let path = Path::new("/tmp/vfa-registry-nonexistent-xyzzy.toml");
let result = WorkspaceRegistry::load(path).unwrap();
assert!(
matches!(result, LoadResult::NotFound(_)),
"expected NotFound"
);
}
#[test]
fn load_empty_registry() {
let f = write_toml("# empty registry\n");
let result = WorkspaceRegistry::load(f.path()).unwrap();
let reg = match result {
LoadResult::Loaded(r) => r,
LoadResult::NotFound(_) => panic!("expected Loaded"),
};
assert!(reg.entries.is_empty());
}
#[test]
fn load_single_entry() {
let toml = r#"
[[workspace]]
path = "/tmp/foo"
name = "foo"
team = "platform"
tags = ["prod"]
"#;
let f = write_toml(toml);
let reg = match WorkspaceRegistry::load(f.path()).unwrap() {
LoadResult::Loaded(r) => r,
LoadResult::NotFound(_) => panic!("expected Loaded"),
};
assert_eq!(reg.entries.len(), 1);
assert_eq!(reg.entries[0].path, "/tmp/foo");
assert_eq!(reg.entries[0].name.as_deref(), Some("foo"));
assert_eq!(reg.entries[0].team.as_deref(), Some("platform"));
}
#[test]
fn load_invalid_toml_returns_error() {
let f = write_toml("[[workspace]\npath = oops");
let err = WorkspaceRegistry::load(f.path()).unwrap_err();
assert!(
matches!(err, TuiError::RegistryParse { .. }),
"unexpected: {err:?}"
);
}
#[test]
fn load_supports_comments() {
let toml = r#"
# This is a comment
[[workspace]]
# Another comment
path = "/tmp/bar"
name = "bar" # inline comment
"#;
let f = write_toml(toml);
let result = WorkspaceRegistry::load(f.path());
assert!(result.is_ok(), "comments should not cause parse error");
}
#[test]
fn load_multiple_entries() {
let toml = r#"
[[workspace]]
path = "/tmp/a"
name = "a"
[[workspace]]
path = "/tmp/b"
name = "b"
"#;
let f = write_toml(toml);
let reg = match WorkspaceRegistry::load(f.path()).unwrap() {
LoadResult::Loaded(r) => r,
_ => panic!("expected Loaded"),
};
assert_eq!(reg.entries.len(), 2);
}
#[test]
fn resolve_existing_path_is_available() {
let dir = tempfile::tempdir().unwrap();
let toml = format!("[[workspace]]\npath = \"{}\"\n", dir.path().display());
let f = write_toml(&toml);
let reg = match WorkspaceRegistry::load(f.path()).unwrap() {
LoadResult::Loaded(r) => r,
_ => panic!("expected Loaded"),
};
let resolved = reg.resolve();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].status, WorkspaceStatus::Available);
}
#[test]
fn resolve_missing_path_is_unavailable() {
let toml = "[[workspace]]\npath = \"/nonexistent/path/xyz\"\n";
let f = write_toml(toml);
let reg = match WorkspaceRegistry::load(f.path()).unwrap() {
LoadResult::Loaded(r) => r,
_ => panic!("expected Loaded"),
};
let resolved = reg.resolve();
assert!(matches!(
resolved[0].status,
WorkspaceStatus::Unavailable(_)
));
}
#[test]
fn resolve_derives_name_from_basename() {
let toml = "[[workspace]]\npath = \"/tmp/my-workspace\"\n";
let f = write_toml(toml);
let reg = match WorkspaceRegistry::load(f.path()).unwrap() {
LoadResult::Loaded(r) => r,
_ => panic!("expected Loaded"),
};
let resolved = reg.resolve();
assert_eq!(resolved[0].name, "my-workspace");
}
#[test]
fn expand_env_with_home() {
let result = WorkspaceRegistry::expand_env_with("$HOME/repos/foo", |name| {
if name == "HOME" {
Some("/home/alice".to_string())
} else {
None
}
});
assert_eq!(result, "/home/alice/repos/foo");
}
#[test]
fn expand_env_with_braces() {
let result = WorkspaceRegistry::expand_env_with("${HOME}/repos", |name| {
if name == "HOME" {
Some("/home/bob".to_string())
} else {
None
}
});
assert_eq!(result, "/home/bob/repos");
}
#[test]
fn expand_env_with_unknown_var_left_literal() {
let result = WorkspaceRegistry::expand_env_with("$UNKNOWN/path", |_| None);
assert_eq!(result, "$UNKNOWN/path");
}
#[test]
fn expand_env_with_unknown_braced_var_left_literal() {
let result = WorkspaceRegistry::expand_env_with("${UNKNOWN}/path", |_| None);
assert_eq!(result, "${UNKNOWN}/path");
}
#[test]
fn expand_env_with_no_vars() {
let result = WorkspaceRegistry::expand_env_with("/home/user/repo", |_| None);
assert_eq!(result, "/home/user/repo");
}
#[test]
fn expand_env_with_trailing_dollar() {
let result = WorkspaceRegistry::expand_env_with("/path/$", |_| None);
assert_eq!(result, "/path/$");
}
#[test]
fn expand_env_with_multiple_vars() {
let result = WorkspaceRegistry::expand_env_with("$HOME/$USER/repos", |name| match name {
"HOME" => Some("/home".to_string()),
"USER" => Some("alice".to_string()),
_ => None,
});
assert_eq!(result, "/home/alice/repos");
}
#[test]
fn validate_rejects_empty_path() {
let toml = "[[workspace]]\npath = \"\"\nname = \"empty-path\"\n";
let f = write_toml(toml);
let reg = match WorkspaceRegistry::load(f.path()).unwrap() {
LoadResult::Loaded(r) => r,
_ => panic!("expected Loaded"),
};
let errs = reg.validate();
assert!(
errs.iter().any(
|e| matches!(e, TuiError::RegistryFieldMissing { field, .. } if field == "path")
),
"expected RegistryFieldMissing for empty path, got: {errs:?}"
);
}
#[test]
fn validate_valid_entry_produces_no_errors() {
let toml = "[[workspace]]\npath = \"/tmp/ok\"\nname = \"ok\"\n";
let f = write_toml(toml);
let reg = match WorkspaceRegistry::load(f.path()).unwrap() {
LoadResult::Loaded(r) => r,
_ => panic!("expected Loaded"),
};
let errs: Vec<_> = reg
.validate()
.into_iter()
.filter(|e| matches!(e, TuiError::RegistryFieldMissing { .. }))
.collect();
assert!(errs.is_empty(), "unexpected errors: {errs:?}");
}
#[test]
fn find_duplicates_detects_same_path() {
let reg = WorkspaceRegistry {
entries: vec![
WorkspaceEntry {
path: "/tmp/foo".to_string(),
name: Some("a".to_string()),
team: None,
tags: None,
policy_overrides: None,
},
WorkspaceEntry {
path: "/tmp/foo".to_string(),
name: Some("b".to_string()),
team: None,
tags: None,
policy_overrides: None,
},
],
path: PathBuf::from("/tmp/test.toml"),
last_loaded: Instant::now(),
};
let dups = reg.find_duplicates();
assert_eq!(dups.len(), 1);
assert_eq!(dups[0].1.len(), 2);
}
#[test]
fn find_duplicates_no_conflicts() {
let reg = WorkspaceRegistry {
entries: vec![
WorkspaceEntry {
path: "/tmp/foo".to_string(),
name: None,
team: None,
tags: None,
policy_overrides: None,
},
WorkspaceEntry {
path: "/tmp/bar".to_string(),
name: None,
team: None,
tags: None,
policy_overrides: None,
},
],
path: PathBuf::from("/tmp/test.toml"),
last_loaded: Instant::now(),
};
assert!(reg.find_duplicates().is_empty());
}
#[test]
fn glob_match_star_prefix() {
assert!(glob_match("foo*", "foobar"));
assert!(glob_match("foo*", "foo"));
assert!(!glob_match("foo*", "barfoo"));
}
#[test]
fn glob_match_star_suffix() {
assert!(glob_match("*bar", "foobar"));
assert!(!glob_match("*bar", "barbaz"));
}
#[test]
fn glob_match_question_mark() {
assert!(glob_match("ba?", "bar"));
assert!(!glob_match("ba?", "ba"));
assert!(!glob_match("ba?", "barr"));
}
#[test]
fn glob_match_exact() {
assert!(glob_match("hello", "hello"));
assert!(!glob_match("hello", "world"));
}
#[test]
fn glob_match_star_only() {
assert!(glob_match("*", "anything"));
assert!(glob_match("*", ""));
}
#[test]
fn filter_matches_by_name() {
let dir = tempfile::tempdir().unwrap();
let reg = WorkspaceRegistry {
entries: vec![WorkspaceEntry {
path: dir.path().display().to_string(),
name: Some("foobar".to_string()),
team: None,
tags: None,
policy_overrides: None,
}],
path: PathBuf::from("/tmp/test.toml"),
last_loaded: Instant::now(),
};
let resolved = reg.resolve();
let matched = reg.filter("foo*", &resolved);
assert_eq!(matched.len(), 1);
assert_eq!(matched[0].name, "foobar");
}
#[test]
fn filter_excludes_non_matching() {
let dir = tempfile::tempdir().unwrap();
let reg = WorkspaceRegistry {
entries: vec![WorkspaceEntry {
path: dir.path().display().to_string(),
name: Some("bazqux".to_string()),
team: None,
tags: None,
policy_overrides: None,
}],
path: PathBuf::from("/tmp/test.toml"),
last_loaded: Instant::now(),
};
let resolved = reg.resolve();
let matched = reg.filter("foo*", &resolved);
assert!(matched.is_empty());
}
#[test]
fn reload_updates_entries() {
let mut f = NamedTempFile::new().unwrap();
write!(f, "[[workspace]]\npath = \"/tmp/a\"\n").unwrap();
f.flush().unwrap();
let mut reg = match WorkspaceRegistry::load(f.path()).unwrap() {
LoadResult::Loaded(r) => r,
_ => panic!("expected Loaded"),
};
assert_eq!(reg.entries.len(), 1);
{
let file = f.as_file_mut();
file.seek(std::io::SeekFrom::Start(0)).unwrap();
file.set_len(0).unwrap();
write!(
file,
"[[workspace]]\npath = \"/tmp/a\"\n[[workspace]]\npath = \"/tmp/b\"\n"
)
.unwrap();
file.flush().unwrap();
}
reg.reload().unwrap();
assert_eq!(reg.entries.len(), 2);
}
#[test]
fn reload_retains_previous_on_invalid_toml() {
let mut f = NamedTempFile::new().unwrap();
write!(f, "[[workspace]]\npath = \"/tmp/a\"\n").unwrap();
f.flush().unwrap();
let mut reg = match WorkspaceRegistry::load(f.path()).unwrap() {
LoadResult::Loaded(r) => r,
_ => panic!("expected Loaded"),
};
{
let file = f.as_file_mut();
file.seek(std::io::SeekFrom::Start(0)).unwrap();
file.set_len(0).unwrap();
write!(file, "[[invalid toml").unwrap();
file.flush().unwrap();
}
let err = reg.reload();
assert!(err.is_err());
assert_eq!(reg.entries.len(), 1, "previous entries should be retained");
}
proptest! {
#![proptest_config(proptest::test_runner::Config {
cases: 256,
..Default::default()
})]
#[test]
fn prop16_toml_round_trip(
entries in prop::collection::vec(arb_workspace_entry(), 0..=10)
) {
let original = RegistryFile { workspaces: entries };
let serialised = toml::to_string(&original)
.expect("serialisation must succeed for valid data");
let parsed: RegistryFile = toml::from_str(&serialised)
.expect("re-parse of serialised data must succeed");
prop_assert_eq!(original.workspaces.len(), parsed.workspaces.len());
for (orig, got) in original.workspaces.iter().zip(parsed.workspaces.iter()) {
prop_assert_eq!(&orig.path, &got.path);
prop_assert_eq!(&orig.name, &got.name);
prop_assert_eq!(&orig.team, &got.team);
prop_assert_eq!(&orig.tags, &got.tags);
match (&orig.policy_overrides, &got.policy_overrides) {
(None, None) => {}
(Some(_), Some(_)) => {}
_ => prop_assert!(false, "policy_overrides presence mismatch"),
}
}
}
#[test]
fn prop17_validation_missing_path_rejected(
name in prop::option::of("[a-z][a-z0-9-]{0,15}"),
team in prop::option::of("[a-z][a-z0-9-]{0,10}"),
) {
let entry = WorkspaceEntry {
path: "".to_string(), name,
team,
tags: None,
policy_overrides: None,
};
let reg = WorkspaceRegistry {
entries: vec![entry],
path: PathBuf::from("/tmp/test.toml"),
last_loaded: Instant::now(),
};
let errs = reg.validate();
let has_missing = errs.iter().any(|e| {
matches!(e, TuiError::RegistryFieldMissing { field, .. } if field == "path")
});
prop_assert!(has_missing, "expected RegistryFieldMissing for empty path");
}
#[test]
fn prop17b_validation_valid_entry_no_field_errors(
path in "[a-z/][a-z0-9/_-]{1,30}",
name in prop::option::of("[a-z][a-z0-9-]{0,15}"),
) {
let entry = WorkspaceEntry {
path: format!("/tmp/{path}"),
name,
team: None,
tags: None,
policy_overrides: None,
};
let reg = WorkspaceRegistry {
entries: vec![entry],
path: PathBuf::from("/tmp/test.toml"),
last_loaded: Instant::now(),
};
let errs = reg.validate();
let field_errs: Vec<_> = errs
.iter()
.filter(|e| matches!(e, TuiError::RegistryFieldMissing { .. }))
.collect();
prop_assert!(
field_errs.is_empty(),
"valid entry should have no field errors, got: {field_errs:?}"
);
}
#[test]
fn prop29_expand_env_safe(
input in "[ -~]{0,80}", ) {
let home_val = "/home/testuser";
let result = WorkspaceRegistry::expand_env_with(&input, |name| {
match name {
"HOME" => Some(home_val.to_string()),
"USER" => Some("testuser".to_string()),
_ => None,
}
});
if input.contains("$HOME") || input.contains("${HOME}") {
prop_assert!(
!result.contains("$HOME") && !result.contains("${HOME}"),
"HOME var was not expanded: input={input:?}, result={result:?}"
);
}
let _ = result.len(); }
#[test]
fn prop29b_expand_env_home_always_expands(
suffix in "/[a-z0-9/_-]{0,30}",
) {
let home_val = "/home/proptest-user";
let input = format!("$HOME{suffix}");
let result = WorkspaceRegistry::expand_env_with(&input, |name| {
if name == "HOME" {
Some(home_val.to_string())
} else {
None
}
});
prop_assert!(
result.starts_with(home_val),
"expected result to start with HOME value: input={input:?}, result={result:?}"
);
}
#[test]
fn prop31_glob_filter_foo_star(
name in "[a-z]{1,20}",
) {
let dir = std::env::temp_dir();
let ws = ResolvedWorkspace {
canonical_path: dir.clone(),
name: name.clone(),
team: None,
tags: vec![],
status: WorkspaceStatus::Unavailable("test".to_string()),
};
let reg = WorkspaceRegistry {
entries: vec![],
path: PathBuf::from("/tmp/test.toml"),
last_loaded: Instant::now(),
};
let resolved = vec![ws];
let matched = reg.filter("foo*", &resolved);
let starts_with_foo = name.starts_with("foo");
let name_matches = glob_match("foo*", &name);
prop_assert_eq!(starts_with_foo, name_matches, "glob matching inconsistency");
prop_assert_eq!(name_matches, !matched.is_empty(), "filter result inconsistency");
}
}
fn arb_workspace_entry() -> impl Strategy<Value = WorkspaceEntry> {
(
"/[a-z][a-z0-9/_-]{0,20}",
prop::option::of("[a-z][a-z0-9-]{0,15}"),
prop::option::of("[a-z][a-z0-9-]{0,10}"),
prop::option::of(prop::collection::vec("[a-z][a-z0-9-]{0,8}", 0..=4)),
)
.prop_map(|(path, name, team, tags)| WorkspaceEntry {
path,
name,
team,
tags,
policy_overrides: None, })
}
}