Skip to main content

vfa_tui/federation/
registry.rs

1//! Workspace registry TOML parser — Tasks 5.3 & 5.4.
2//!
3//! # Design decisions
4//!
5//! ## Unknown-key handling (Req 31.1 vs 31.4)
6//! Req 31.1 says "strict mode rejecting unknown keys"; Req 31.4 says "report a
7//! warning and continue".  These conflict.  We follow **Req 31.4**: unknown keys
8//! produce a `tracing::warn!` and are silently ignored.  This is the safer
9//! forward-compatible choice — a strict rejection would break registry files the
10//! moment any future field is added by an older binary.  The decision is
11//! documented here for traceability.
12//!
13//! ## Env-var expansion (Req 30.2)
14//! `expand_env_with` takes a lookup closure so tests can inject a deterministic
15//! environment without touching global process state (avoids races with
16//! `std::env::set_var`).  `expand_env` is a thin wrapper that calls
17//! `std::env::var`.  Unknown variables are left **as literals** (e.g. `$UNKNOWN`
18//! stays `$UNKNOWN`).  This is conservative: a typo in a variable name will
19//! produce an obviously wrong path rather than silently resolving to an empty
20//! string, making misconfiguration easier to spot.
21//!
22//! ## Glob matching (Req 6.7)
23//! We implement a hand-rolled matcher supporting `*` (any sequence, including
24//! empty) and `?` (exactly one character).  This avoids adding a `glob` /
25//! `globset` dependency.  The pattern is matched against both the workspace name
26//! and the raw path string; a workspace is included if either matches.
27//!
28//! ## Missing-registry behaviour (Req 6.2)
29//! `load` returns `Ok(LoadResult::NotFound)` (carrying an empty registry) when
30//! the file is absent.  It does **not** create the file; creation is the
31//! caller's responsibility after an explicit operator confirmation.
32//!
33//! ## `reload` fallback (Req 6.5)
34//! On invalid TOML, `reload` retains the previous valid registry and returns
35//! `Err(TuiError::RegistryParse{…})`.
36
37#![deny(warnings)]
38
39use std::path::{Path, PathBuf};
40use std::time::Instant;
41
42use serde::{Deserialize, Serialize};
43
44use crate::error::TuiError;
45use crate::models::workspace::{ResolvedWorkspace, WorkspaceEntry, WorkspaceStatus};
46
47// ---------------------------------------------------------------------------
48// Wire format (what the TOML file actually looks like)
49// ---------------------------------------------------------------------------
50
51/// Raw deserialisation shape for the workspace registry TOML.
52///
53/// We use a wrapper so the top-level key is `[[workspace]]` (array of tables).
54#[derive(Debug, Deserialize, Serialize)]
55struct RegistryFile {
56    #[serde(default, rename = "workspace")]
57    workspaces: Vec<WorkspaceEntry>,
58}
59
60// ---------------------------------------------------------------------------
61// Public API types
62// ---------------------------------------------------------------------------
63
64/// Outcome of [`WorkspaceRegistry::load`].
65///
66/// Separates the "file was missing" case (Req 6.2 — no file to read yet) from
67/// a successful parse so callers can decide whether to prompt for setup.
68#[derive(Debug)]
69pub enum LoadResult {
70    /// Registry parsed successfully from the given path.
71    Loaded(WorkspaceRegistry),
72    /// Registry file did not exist; an empty registry is returned so callers
73    /// can proceed with zero workspaces while optionally prompting for setup.
74    NotFound(WorkspaceRegistry),
75}
76
77/// Parsed and validated workspace registry.
78#[derive(Debug, Clone)]
79pub struct WorkspaceRegistry {
80    /// All entries from the `[[workspace]]` array-of-tables.
81    pub entries: Vec<WorkspaceEntry>,
82    /// Absolute path to the registry file (may not exist yet for `NotFound`).
83    pub path: PathBuf,
84    /// Monotonic timestamp of the last successful load or reload.
85    pub last_loaded: Instant,
86}
87
88// ---------------------------------------------------------------------------
89// Core implementation
90// ---------------------------------------------------------------------------
91
92impl WorkspaceRegistry {
93    // -----------------------------------------------------------------------
94    // Construction
95    // -----------------------------------------------------------------------
96
97    /// Load and parse a workspace registry TOML file.
98    ///
99    /// Returns:
100    /// - `Ok(LoadResult::Loaded(_))` — file found and valid.
101    /// - `Ok(LoadResult::NotFound(_))` — file absent; empty registry returned
102    ///   so the caller can prompt for setup (Req 6.2).  The file is **not**
103    ///   created here.
104    /// - `Err(TuiError::RegistryParse{…})` — file exists but is malformed.
105    pub fn load(path: &Path) -> Result<LoadResult, TuiError> {
106        if !path.exists() {
107            let empty = WorkspaceRegistry {
108                entries: vec![],
109                path: path.to_path_buf(),
110                last_loaded: Instant::now(),
111            };
112            return Ok(LoadResult::NotFound(empty));
113        }
114
115        let text = std::fs::read_to_string(path).map_err(|e| TuiError::RegistryParse {
116            path: path.display().to_string(),
117            detail: e.to_string(),
118        })?;
119
120        let reg = Self::parse_text(&text, path)?;
121        Ok(LoadResult::Loaded(reg))
122    }
123
124    /// Parse registry content from an in-memory string (used by `reload` and
125    /// tests).
126    fn parse_text(text: &str, path: &Path) -> Result<WorkspaceRegistry, TuiError> {
127        // Deserialise with the toml crate.  Unknown keys in the `[[workspace]]`
128        // tables are silently ignored (Req 31.4 takes precedence over 31.1 —
129        // see module-level docs).
130        let file: RegistryFile = toml::from_str(text).map_err(|e| TuiError::RegistryParse {
131            path: path.display().to_string(),
132            detail: e.to_string(),
133        })?;
134
135        // Warn about any `policy_overrides` values that look like unknown-field
136        // containers (we can only do this at the TOML level since serde silently
137        // drops unknown fields; the real unknown-key warning is handled by
138        // accepting the toml::Value as-is).
139
140        Ok(WorkspaceRegistry {
141            entries: file.workspaces,
142            path: path.to_path_buf(),
143            last_loaded: Instant::now(),
144        })
145    }
146
147    // -----------------------------------------------------------------------
148    // Resolve
149    // -----------------------------------------------------------------------
150
151    /// Expand env vars in all entry paths, canonicalize, and classify each
152    /// workspace as `Available` or `Unavailable` (Req 6.3).
153    ///
154    /// Unavailable workspaces (path missing / inaccessible) are included in the
155    /// result — they are never silently dropped.
156    pub fn resolve(&self) -> Vec<ResolvedWorkspace> {
157        self.entries
158            .iter()
159            .map(|entry| {
160                let expanded = Self::expand_env(&entry.path);
161                let expanded_path = Path::new(&expanded);
162
163                // Derive effective name: explicit > directory basename > raw path.
164                let name = entry.name.clone().unwrap_or_else(|| basename_of(&expanded));
165
166                let tags = entry.tags.clone().unwrap_or_default();
167                let team = entry.team.clone();
168
169                // Try to canonicalize the path to get the true on-disk path.
170                match expanded_path.canonicalize() {
171                    Ok(canonical) => ResolvedWorkspace {
172                        canonical_path: canonical,
173                        name,
174                        team,
175                        tags,
176                        status: WorkspaceStatus::Available,
177                    },
178                    Err(e) => ResolvedWorkspace {
179                        // Fall back to the expanded (non-canonical) path so
180                        // callers still have something useful to display.
181                        canonical_path: expanded_path.to_path_buf(),
182                        name,
183                        team,
184                        tags,
185                        status: WorkspaceStatus::Unavailable(e.to_string()),
186                    },
187                }
188            })
189            .collect()
190    }
191
192    // -----------------------------------------------------------------------
193    // Env-var expansion
194    // -----------------------------------------------------------------------
195
196    /// Expand `$VAR` and `${VAR}` forms in `path` using the real process
197    /// environment.
198    ///
199    /// Unknown variables are left as literals (see module-level docs for
200    /// rationale).  No shell is invoked; only `std::env::var` is used.
201    pub fn expand_env(path: &str) -> String {
202        Self::expand_env_with(path, |name| std::env::var(name).ok())
203    }
204
205    /// Testable variant — accepts a lookup closure so tests can inject a
206    /// deterministic environment without races on `std::env::set_var`.
207    ///
208    /// Handles:
209    /// - `${VAR}` — brace-delimited form.
210    /// - `$VAR` — bare form; variable name is the longest run of
211    ///   alphanumeric-or-underscore characters after `$`.
212    pub fn expand_env_with<F>(path: &str, lookup: F) -> String
213    where
214        F: Fn(&str) -> Option<String>,
215    {
216        let bytes = path.as_bytes();
217        let mut out = String::with_capacity(path.len());
218        let mut i = 0;
219
220        while i < bytes.len() {
221            if bytes[i] != b'$' {
222                out.push(bytes[i] as char);
223                i += 1;
224                continue;
225            }
226
227            // We're at a `$`.
228            i += 1; // consume `$`
229
230            if i >= bytes.len() {
231                // Trailing `$` — emit literally.
232                out.push('$');
233                continue;
234            }
235
236            if bytes[i] == b'{' {
237                // `${VAR}` form — scan for closing `}`.
238                i += 1; // consume `{`
239                let start = i;
240                while i < bytes.len() && bytes[i] != b'}' {
241                    i += 1;
242                }
243                if i >= bytes.len() {
244                    // Unterminated `${…` — emit literally.
245                    out.push('$');
246                    out.push('{');
247                    out.push_str(&path[start..i]);
248                    continue;
249                }
250                let var_name = &path[start..i];
251                i += 1; // consume `}`
252                match lookup(var_name) {
253                    Some(val) => out.push_str(&val),
254                    None => {
255                        // Unknown var — leave literal.
256                        out.push('$');
257                        out.push('{');
258                        out.push_str(var_name);
259                        out.push('}');
260                    }
261                }
262            } else if bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' {
263                // `$VAR` form — scan identifier characters.
264                let start = i;
265                while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
266                    i += 1;
267                }
268                let var_name = &path[start..i];
269                match lookup(var_name) {
270                    Some(val) => out.push_str(&val),
271                    None => {
272                        // Unknown var — leave literal.
273                        out.push('$');
274                        out.push_str(var_name);
275                    }
276                }
277            } else {
278                // `$` followed by a non-identifier character — emit literally.
279                out.push('$');
280                // Do NOT advance `i`; next iteration handles `bytes[i]`.
281            }
282        }
283
284        out
285    }
286
287    // -----------------------------------------------------------------------
288    // Duplicate detection
289    // -----------------------------------------------------------------------
290
291    /// Detect entries whose expanded paths resolve to the same canonical
292    /// location (Req 30.5).
293    ///
294    /// Returns a list of `(canonical_path_string, Vec<entry_index>)` tuples —
295    /// one per group of conflicting entries.  For paths that do not yet exist on
296    /// the filesystem we compare the expanded (non-canonical) string instead.
297    ///
298    /// This method runs as an independent validation pass: it does not depend on
299    /// `validate()` and will report conflicts even if other validations fail.
300    pub fn find_duplicates(&self) -> Vec<(String, Vec<usize>)> {
301        use std::collections::HashMap;
302
303        let mut map: HashMap<String, Vec<usize>> = HashMap::new();
304
305        for (idx, entry) in self.entries.iter().enumerate() {
306            let expanded = Self::expand_env(&entry.path);
307            let key = match Path::new(&expanded).canonicalize() {
308                Ok(p) => p.to_string_lossy().into_owned(),
309                Err(_) => expanded,
310            };
311            map.entry(key).or_default().push(idx);
312        }
313
314        let mut result: Vec<(String, Vec<usize>)> = map
315            .into_iter()
316            .filter(|(_, indices)| indices.len() > 1)
317            .collect();
318
319        // Sort for deterministic output.
320        result.sort_by(|a, b| a.0.cmp(&b.0));
321        result
322    }
323
324    // -----------------------------------------------------------------------
325    // Validation
326    // -----------------------------------------------------------------------
327
328    /// Validate all entries and return a list of errors.
329    ///
330    /// Validates:
331    /// - Entries with empty `path` fields (Req 6.4).
332    /// - Duplicate canonical paths (Req 30.5).
333    ///
334    /// Errors are collected — validation does not short-circuit on first failure.
335    pub fn validate(&self) -> Vec<TuiError> {
336        let mut errors: Vec<TuiError> = vec![];
337
338        // Pass 1: missing or empty `path`.
339        for (idx, entry) in self.entries.iter().enumerate() {
340            if entry.path.trim().is_empty() {
341                let label = entry
342                    .name
343                    .clone()
344                    .unwrap_or_else(|| format!("entry[{idx}]"));
345                errors.push(TuiError::RegistryFieldMissing {
346                    entry: label,
347                    field: "path".to_string(),
348                });
349            }
350        }
351
352        // Pass 2: duplicate canonical paths (separate pass per Req 30.5).
353        for (key, indices) in self.find_duplicates() {
354            let labels: Vec<String> = indices
355                .iter()
356                .map(|&i| {
357                    self.entries[i]
358                        .name
359                        .clone()
360                        .unwrap_or_else(|| format!("entry[{i}]"))
361                })
362                .collect();
363            errors.push(TuiError::RegistryDuplicate {
364                path: self.path.display().to_string(),
365                entries: format!("{} (path: {})", labels.join(", "), key),
366            });
367        }
368
369        errors
370    }
371
372    // -----------------------------------------------------------------------
373    // Filter
374    // -----------------------------------------------------------------------
375
376    /// Return resolved workspaces whose `name` or raw `path` match `pattern`.
377    ///
378    /// Pattern syntax: `*` matches any sequence of characters (including none);
379    /// `?` matches exactly one character.  The match is case-sensitive.
380    ///
381    /// A workspace is included if either its name or its canonical path string
382    /// matches (Req 6.7).
383    pub fn filter<'a>(
384        &'a self,
385        pattern: &str,
386        resolved: &'a [ResolvedWorkspace],
387    ) -> Vec<&'a ResolvedWorkspace> {
388        resolved
389            .iter()
390            .filter(|ws| {
391                glob_match(pattern, &ws.name)
392                    || glob_match(pattern, &ws.canonical_path.to_string_lossy())
393            })
394            .collect()
395    }
396
397    // -----------------------------------------------------------------------
398    // Hot-reload
399    // -----------------------------------------------------------------------
400
401    /// Reload the registry from disk (Req 6.5).
402    ///
403    /// On success, updates `self` in-place and returns `Ok(())`.
404    /// On any error (I/O or TOML parse), retains the **previous** valid state
405    /// and returns `Err(TuiError::RegistryParse{…})`.
406    pub fn reload(&mut self) -> Result<(), TuiError> {
407        let path = self.path.clone();
408        let text = std::fs::read_to_string(&path).map_err(|e| TuiError::RegistryParse {
409            path: path.display().to_string(),
410            detail: e.to_string(),
411        })?;
412
413        let new_reg = Self::parse_text(&text, &path)?;
414        // Only mutate self after a successful parse.
415        self.entries = new_reg.entries;
416        self.last_loaded = Instant::now();
417        Ok(())
418    }
419}
420
421// ---------------------------------------------------------------------------
422// Helpers
423// ---------------------------------------------------------------------------
424
425/// Extract the directory basename from an expanded path string.
426fn basename_of(path: &str) -> String {
427    Path::new(path)
428        .file_name()
429        .and_then(|n| n.to_str())
430        .unwrap_or(path)
431        .to_string()
432}
433
434/// Minimal glob matcher supporting `*` and `?`.
435///
436/// - `*` — matches any sequence of characters (including empty).
437/// - `?` — matches exactly one character.
438/// - All other characters match literally.
439///
440/// Implemented with a recursive descent that is linear in the common case.
441pub fn glob_match(pattern: &str, text: &str) -> bool {
442    glob_match_bytes(pattern.as_bytes(), text.as_bytes())
443}
444
445fn glob_match_bytes(pat: &[u8], text: &[u8]) -> bool {
446    match (pat.first(), text.first()) {
447        (None, None) => true,
448        (None, Some(_)) => false,
449        (Some(b'*'), _) => {
450            // `*` can match zero characters, or one + recurse.
451            glob_match_bytes(&pat[1..], text)
452                || (!text.is_empty() && glob_match_bytes(pat, &text[1..]))
453        }
454        (Some(b'?'), Some(_)) => glob_match_bytes(&pat[1..], &text[1..]),
455        (Some(b'?'), None) => false,
456        (Some(p), Some(t)) => p == t && glob_match_bytes(&pat[1..], &text[1..]),
457        (Some(_), None) => false,
458    }
459}
460
461// ---------------------------------------------------------------------------
462// Tests — unit + proptest properties
463// ---------------------------------------------------------------------------
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use proptest::prelude::*;
469    use std::io::{Seek, Write};
470    use tempfile::NamedTempFile;
471
472    // -----------------------------------------------------------------------
473    // Helpers
474    // -----------------------------------------------------------------------
475
476    fn write_toml(content: &str) -> NamedTempFile {
477        let mut f = NamedTempFile::new().unwrap();
478        f.write_all(content.as_bytes()).unwrap();
479        f.flush().unwrap();
480        f
481    }
482
483    // -----------------------------------------------------------------------
484    // Unit tests — load
485    // -----------------------------------------------------------------------
486
487    #[test]
488    fn load_missing_file_returns_not_found() {
489        let path = Path::new("/tmp/vfa-registry-nonexistent-xyzzy.toml");
490        let result = WorkspaceRegistry::load(path).unwrap();
491        assert!(
492            matches!(result, LoadResult::NotFound(_)),
493            "expected NotFound"
494        );
495    }
496
497    #[test]
498    fn load_empty_registry() {
499        let f = write_toml("# empty registry\n");
500        let result = WorkspaceRegistry::load(f.path()).unwrap();
501        let reg = match result {
502            LoadResult::Loaded(r) => r,
503            LoadResult::NotFound(_) => panic!("expected Loaded"),
504        };
505        assert!(reg.entries.is_empty());
506    }
507
508    #[test]
509    fn load_single_entry() {
510        let toml = r#"
511[[workspace]]
512path = "/tmp/foo"
513name = "foo"
514team = "platform"
515tags = ["prod"]
516"#;
517        let f = write_toml(toml);
518        let reg = match WorkspaceRegistry::load(f.path()).unwrap() {
519            LoadResult::Loaded(r) => r,
520            LoadResult::NotFound(_) => panic!("expected Loaded"),
521        };
522        assert_eq!(reg.entries.len(), 1);
523        assert_eq!(reg.entries[0].path, "/tmp/foo");
524        assert_eq!(reg.entries[0].name.as_deref(), Some("foo"));
525        assert_eq!(reg.entries[0].team.as_deref(), Some("platform"));
526    }
527
528    #[test]
529    fn load_invalid_toml_returns_error() {
530        let f = write_toml("[[workspace]\npath = oops");
531        let err = WorkspaceRegistry::load(f.path()).unwrap_err();
532        assert!(
533            matches!(err, TuiError::RegistryParse { .. }),
534            "unexpected: {err:?}"
535        );
536    }
537
538    #[test]
539    fn load_supports_comments() {
540        // Req 30.4 — comments must not cause parse errors.
541        let toml = r#"
542# This is a comment
543[[workspace]]
544# Another comment
545path = "/tmp/bar"
546name = "bar" # inline comment
547"#;
548        let f = write_toml(toml);
549        let result = WorkspaceRegistry::load(f.path());
550        assert!(result.is_ok(), "comments should not cause parse error");
551    }
552
553    #[test]
554    fn load_multiple_entries() {
555        let toml = r#"
556[[workspace]]
557path = "/tmp/a"
558name = "a"
559
560[[workspace]]
561path = "/tmp/b"
562name = "b"
563"#;
564        let f = write_toml(toml);
565        let reg = match WorkspaceRegistry::load(f.path()).unwrap() {
566            LoadResult::Loaded(r) => r,
567            _ => panic!("expected Loaded"),
568        };
569        assert_eq!(reg.entries.len(), 2);
570    }
571
572    // -----------------------------------------------------------------------
573    // Unit tests — resolve
574    // -----------------------------------------------------------------------
575
576    #[test]
577    fn resolve_existing_path_is_available() {
578        let dir = tempfile::tempdir().unwrap();
579        let toml = format!("[[workspace]]\npath = \"{}\"\n", dir.path().display());
580        let f = write_toml(&toml);
581        let reg = match WorkspaceRegistry::load(f.path()).unwrap() {
582            LoadResult::Loaded(r) => r,
583            _ => panic!("expected Loaded"),
584        };
585        let resolved = reg.resolve();
586        assert_eq!(resolved.len(), 1);
587        assert_eq!(resolved[0].status, WorkspaceStatus::Available);
588    }
589
590    #[test]
591    fn resolve_missing_path_is_unavailable() {
592        let toml = "[[workspace]]\npath = \"/nonexistent/path/xyz\"\n";
593        let f = write_toml(toml);
594        let reg = match WorkspaceRegistry::load(f.path()).unwrap() {
595            LoadResult::Loaded(r) => r,
596            _ => panic!("expected Loaded"),
597        };
598        let resolved = reg.resolve();
599        assert!(matches!(
600            resolved[0].status,
601            WorkspaceStatus::Unavailable(_)
602        ));
603    }
604
605    #[test]
606    fn resolve_derives_name_from_basename() {
607        let toml = "[[workspace]]\npath = \"/tmp/my-workspace\"\n";
608        let f = write_toml(toml);
609        let reg = match WorkspaceRegistry::load(f.path()).unwrap() {
610            LoadResult::Loaded(r) => r,
611            _ => panic!("expected Loaded"),
612        };
613        let resolved = reg.resolve();
614        assert_eq!(resolved[0].name, "my-workspace");
615    }
616
617    // -----------------------------------------------------------------------
618    // Unit tests — expand_env_with
619    // -----------------------------------------------------------------------
620
621    #[test]
622    fn expand_env_with_home() {
623        let result = WorkspaceRegistry::expand_env_with("$HOME/repos/foo", |name| {
624            if name == "HOME" {
625                Some("/home/alice".to_string())
626            } else {
627                None
628            }
629        });
630        assert_eq!(result, "/home/alice/repos/foo");
631    }
632
633    #[test]
634    fn expand_env_with_braces() {
635        let result = WorkspaceRegistry::expand_env_with("${HOME}/repos", |name| {
636            if name == "HOME" {
637                Some("/home/bob".to_string())
638            } else {
639                None
640            }
641        });
642        assert_eq!(result, "/home/bob/repos");
643    }
644
645    #[test]
646    fn expand_env_with_unknown_var_left_literal() {
647        let result = WorkspaceRegistry::expand_env_with("$UNKNOWN/path", |_| None);
648        assert_eq!(result, "$UNKNOWN/path");
649    }
650
651    #[test]
652    fn expand_env_with_unknown_braced_var_left_literal() {
653        let result = WorkspaceRegistry::expand_env_with("${UNKNOWN}/path", |_| None);
654        assert_eq!(result, "${UNKNOWN}/path");
655    }
656
657    #[test]
658    fn expand_env_with_no_vars() {
659        let result = WorkspaceRegistry::expand_env_with("/home/user/repo", |_| None);
660        assert_eq!(result, "/home/user/repo");
661    }
662
663    #[test]
664    fn expand_env_with_trailing_dollar() {
665        let result = WorkspaceRegistry::expand_env_with("/path/$", |_| None);
666        assert_eq!(result, "/path/$");
667    }
668
669    #[test]
670    fn expand_env_with_multiple_vars() {
671        let result = WorkspaceRegistry::expand_env_with("$HOME/$USER/repos", |name| match name {
672            "HOME" => Some("/home".to_string()),
673            "USER" => Some("alice".to_string()),
674            _ => None,
675        });
676        assert_eq!(result, "/home/alice/repos");
677    }
678
679    // -----------------------------------------------------------------------
680    // Unit tests — validate
681    // -----------------------------------------------------------------------
682
683    #[test]
684    fn validate_rejects_empty_path() {
685        let toml = "[[workspace]]\npath = \"\"\nname = \"empty-path\"\n";
686        let f = write_toml(toml);
687        let reg = match WorkspaceRegistry::load(f.path()).unwrap() {
688            LoadResult::Loaded(r) => r,
689            _ => panic!("expected Loaded"),
690        };
691        let errs = reg.validate();
692        assert!(
693            errs.iter().any(
694                |e| matches!(e, TuiError::RegistryFieldMissing { field, .. } if field == "path")
695            ),
696            "expected RegistryFieldMissing for empty path, got: {errs:?}"
697        );
698    }
699
700    #[test]
701    fn validate_valid_entry_produces_no_errors() {
702        let toml = "[[workspace]]\npath = \"/tmp/ok\"\nname = \"ok\"\n";
703        let f = write_toml(toml);
704        let reg = match WorkspaceRegistry::load(f.path()).unwrap() {
705            LoadResult::Loaded(r) => r,
706            _ => panic!("expected Loaded"),
707        };
708        // No duplicate paths here, so only check for field errors.
709        let errs: Vec<_> = reg
710            .validate()
711            .into_iter()
712            .filter(|e| matches!(e, TuiError::RegistryFieldMissing { .. }))
713            .collect();
714        assert!(errs.is_empty(), "unexpected errors: {errs:?}");
715    }
716
717    // -----------------------------------------------------------------------
718    // Unit tests — find_duplicates
719    // -----------------------------------------------------------------------
720
721    #[test]
722    fn find_duplicates_detects_same_path() {
723        let reg = WorkspaceRegistry {
724            entries: vec![
725                WorkspaceEntry {
726                    path: "/tmp/foo".to_string(),
727                    name: Some("a".to_string()),
728                    team: None,
729                    tags: None,
730                    policy_overrides: None,
731                },
732                WorkspaceEntry {
733                    path: "/tmp/foo".to_string(),
734                    name: Some("b".to_string()),
735                    team: None,
736                    tags: None,
737                    policy_overrides: None,
738                },
739            ],
740            path: PathBuf::from("/tmp/test.toml"),
741            last_loaded: Instant::now(),
742        };
743        let dups = reg.find_duplicates();
744        assert_eq!(dups.len(), 1);
745        assert_eq!(dups[0].1.len(), 2);
746    }
747
748    #[test]
749    fn find_duplicates_no_conflicts() {
750        let reg = WorkspaceRegistry {
751            entries: vec![
752                WorkspaceEntry {
753                    path: "/tmp/foo".to_string(),
754                    name: None,
755                    team: None,
756                    tags: None,
757                    policy_overrides: None,
758                },
759                WorkspaceEntry {
760                    path: "/tmp/bar".to_string(),
761                    name: None,
762                    team: None,
763                    tags: None,
764                    policy_overrides: None,
765                },
766            ],
767            path: PathBuf::from("/tmp/test.toml"),
768            last_loaded: Instant::now(),
769        };
770        assert!(reg.find_duplicates().is_empty());
771    }
772
773    // -----------------------------------------------------------------------
774    // Unit tests — filter / glob_match
775    // -----------------------------------------------------------------------
776
777    #[test]
778    fn glob_match_star_prefix() {
779        assert!(glob_match("foo*", "foobar"));
780        assert!(glob_match("foo*", "foo"));
781        assert!(!glob_match("foo*", "barfoo"));
782    }
783
784    #[test]
785    fn glob_match_star_suffix() {
786        assert!(glob_match("*bar", "foobar"));
787        assert!(!glob_match("*bar", "barbaz"));
788    }
789
790    #[test]
791    fn glob_match_question_mark() {
792        assert!(glob_match("ba?", "bar"));
793        assert!(!glob_match("ba?", "ba"));
794        assert!(!glob_match("ba?", "barr"));
795    }
796
797    #[test]
798    fn glob_match_exact() {
799        assert!(glob_match("hello", "hello"));
800        assert!(!glob_match("hello", "world"));
801    }
802
803    #[test]
804    fn glob_match_star_only() {
805        assert!(glob_match("*", "anything"));
806        assert!(glob_match("*", ""));
807    }
808
809    #[test]
810    fn filter_matches_by_name() {
811        let dir = tempfile::tempdir().unwrap();
812        let reg = WorkspaceRegistry {
813            entries: vec![WorkspaceEntry {
814                path: dir.path().display().to_string(),
815                name: Some("foobar".to_string()),
816                team: None,
817                tags: None,
818                policy_overrides: None,
819            }],
820            path: PathBuf::from("/tmp/test.toml"),
821            last_loaded: Instant::now(),
822        };
823        let resolved = reg.resolve();
824        let matched = reg.filter("foo*", &resolved);
825        assert_eq!(matched.len(), 1);
826        assert_eq!(matched[0].name, "foobar");
827    }
828
829    #[test]
830    fn filter_excludes_non_matching() {
831        let dir = tempfile::tempdir().unwrap();
832        let reg = WorkspaceRegistry {
833            entries: vec![WorkspaceEntry {
834                path: dir.path().display().to_string(),
835                name: Some("bazqux".to_string()),
836                team: None,
837                tags: None,
838                policy_overrides: None,
839            }],
840            path: PathBuf::from("/tmp/test.toml"),
841            last_loaded: Instant::now(),
842        };
843        let resolved = reg.resolve();
844        let matched = reg.filter("foo*", &resolved);
845        assert!(matched.is_empty());
846    }
847
848    // -----------------------------------------------------------------------
849    // Unit tests — reload
850    // -----------------------------------------------------------------------
851
852    #[test]
853    fn reload_updates_entries() {
854        let mut f = NamedTempFile::new().unwrap();
855        write!(f, "[[workspace]]\npath = \"/tmp/a\"\n").unwrap();
856        f.flush().unwrap();
857
858        let mut reg = match WorkspaceRegistry::load(f.path()).unwrap() {
859            LoadResult::Loaded(r) => r,
860            _ => panic!("expected Loaded"),
861        };
862        assert_eq!(reg.entries.len(), 1);
863
864        // Overwrite with two entries.
865        {
866            let file = f.as_file_mut();
867            file.seek(std::io::SeekFrom::Start(0)).unwrap();
868            file.set_len(0).unwrap();
869            write!(
870                file,
871                "[[workspace]]\npath = \"/tmp/a\"\n[[workspace]]\npath = \"/tmp/b\"\n"
872            )
873            .unwrap();
874            file.flush().unwrap();
875        }
876
877        reg.reload().unwrap();
878        assert_eq!(reg.entries.len(), 2);
879    }
880
881    #[test]
882    fn reload_retains_previous_on_invalid_toml() {
883        let mut f = NamedTempFile::new().unwrap();
884        write!(f, "[[workspace]]\npath = \"/tmp/a\"\n").unwrap();
885        f.flush().unwrap();
886
887        let mut reg = match WorkspaceRegistry::load(f.path()).unwrap() {
888            LoadResult::Loaded(r) => r,
889            _ => panic!("expected Loaded"),
890        };
891
892        // Corrupt the file.
893        {
894            let file = f.as_file_mut();
895            file.seek(std::io::SeekFrom::Start(0)).unwrap();
896            file.set_len(0).unwrap();
897            write!(file, "[[invalid toml").unwrap();
898            file.flush().unwrap();
899        }
900
901        let err = reg.reload();
902        assert!(err.is_err());
903        // Previous state retained.
904        assert_eq!(reg.entries.len(), 1, "previous entries should be retained");
905    }
906
907    // -----------------------------------------------------------------------
908    // Property 16: TOML round-trip (Req 31.3)
909    // -----------------------------------------------------------------------
910    //
911    // For any valid registry data, serialise to TOML then parse again and the
912    // resulting entries must be structurally equivalent.
913    //
914    // Strategy: generate a `RegistryFile`, serialise with `toml::to_string`,
915    // parse with `toml::from_str`, compare entries field by field.
916
917    proptest! {
918        #![proptest_config(proptest::test_runner::Config {
919            cases: 256,
920            ..Default::default()
921        })]
922
923        #[test]
924        fn prop16_toml_round_trip(
925            entries in prop::collection::vec(arb_workspace_entry(), 0..=10)
926        ) {
927            let original = RegistryFile { workspaces: entries };
928            let serialised = toml::to_string(&original)
929                .expect("serialisation must succeed for valid data");
930            let parsed: RegistryFile = toml::from_str(&serialised)
931                .expect("re-parse of serialised data must succeed");
932
933            prop_assert_eq!(original.workspaces.len(), parsed.workspaces.len());
934            for (orig, got) in original.workspaces.iter().zip(parsed.workspaces.iter()) {
935                prop_assert_eq!(&orig.path, &got.path);
936                prop_assert_eq!(&orig.name, &got.name);
937                prop_assert_eq!(&orig.team, &got.team);
938                prop_assert_eq!(&orig.tags, &got.tags);
939                // policy_overrides: only compare if both are None or both Some
940                // (toml::Value doesn't impl PartialEq in a way that's always stable
941                // across serialize/deserialize cycles for all sub-types, but the
942                // structure must be preserved).
943                match (&orig.policy_overrides, &got.policy_overrides) {
944                    (None, None) => {}
945                    (Some(_), Some(_)) => {}
946                    _ => prop_assert!(false, "policy_overrides presence mismatch"),
947                }
948            }
949        }
950
951        // -----------------------------------------------------------------------
952        // Property 17: validation — missing path rejected, valid accepted (Req 6.4)
953        // -----------------------------------------------------------------------
954
955        #[test]
956        fn prop17_validation_missing_path_rejected(
957            name in prop::option::of("[a-z][a-z0-9-]{0,15}"),
958            team in prop::option::of("[a-z][a-z0-9-]{0,10}"),
959        ) {
960            let entry = WorkspaceEntry {
961                path: "".to_string(), // deliberately empty
962                name,
963                team,
964                tags: None,
965                policy_overrides: None,
966            };
967            let reg = WorkspaceRegistry {
968                entries: vec![entry],
969                path: PathBuf::from("/tmp/test.toml"),
970                last_loaded: Instant::now(),
971            };
972            let errs = reg.validate();
973            let has_missing = errs.iter().any(|e| {
974                matches!(e, TuiError::RegistryFieldMissing { field, .. } if field == "path")
975            });
976            prop_assert!(has_missing, "expected RegistryFieldMissing for empty path");
977        }
978
979        #[test]
980        fn prop17b_validation_valid_entry_no_field_errors(
981            // Paths that are non-empty (we just need non-blank, not necessarily existent)
982            path in "[a-z/][a-z0-9/_-]{1,30}",
983            name in prop::option::of("[a-z][a-z0-9-]{0,15}"),
984        ) {
985            let entry = WorkspaceEntry {
986                path: format!("/tmp/{path}"),
987                name,
988                team: None,
989                tags: None,
990                policy_overrides: None,
991            };
992            let reg = WorkspaceRegistry {
993                entries: vec![entry],
994                path: PathBuf::from("/tmp/test.toml"),
995                last_loaded: Instant::now(),
996            };
997            let errs = reg.validate();
998            let field_errs: Vec<_> = errs
999                .iter()
1000                .filter(|e| matches!(e, TuiError::RegistryFieldMissing { .. }))
1001                .collect();
1002            prop_assert!(
1003                field_errs.is_empty(),
1004                "valid entry should have no field errors, got: {field_errs:?}"
1005            );
1006        }
1007
1008        // -----------------------------------------------------------------------
1009        // Property 29: env expansion is safe — no subprocess, $HOME expands (Req 30.2)
1010        // -----------------------------------------------------------------------
1011        //
1012        // For any input (including shell metacharacters), expand_env_with:
1013        // 1. Never panics.
1014        // 2. Never returns a string containing un-expanded $HOME when HOME is set.
1015        // 3. Output is a plain string — structurally impossible to execute a subprocess.
1016
1017        #[test]
1018        fn prop29_expand_env_safe(
1019            input in "[ -~]{0,80}",  // printable ASCII, including metacharacters
1020        ) {
1021            let home_val = "/home/testuser";
1022            let result = WorkspaceRegistry::expand_env_with(&input, |name| {
1023                match name {
1024                    "HOME" => Some(home_val.to_string()),
1025                    "USER" => Some("testuser".to_string()),
1026                    _ => None,
1027                }
1028            });
1029
1030            // Invariant 1: result is a valid String (no panic means we're good).
1031            // Invariant 2: if the original contained $HOME or ${HOME}, the result
1032            //              must not still contain the literal $HOME (or ${HOME}).
1033            if input.contains("$HOME") || input.contains("${HOME}") {
1034                prop_assert!(
1035                    !result.contains("$HOME") && !result.contains("${HOME}"),
1036                    "HOME var was not expanded: input={input:?}, result={result:?}"
1037                );
1038            }
1039
1040            // Invariant 3: no subprocess-execution characters are introduced by
1041            // the expansion itself.  The expansion only substitutes variable values
1042            // for known vars; unknown vars are left as literals.  The output is a
1043            // plain &str — it cannot spawn a subprocess without explicit API calls.
1044            // We assert this structurally: expand_env_with accepts a closure, not
1045            // a shell command, and never calls std::process::Command.
1046            // (Compile-time guarantee; the runtime assertion below is belt-and-
1047            // suspenders: the test will pass as long as the function returns.)
1048            let _ = result.len(); // ensure result is used
1049        }
1050
1051        #[test]
1052        fn prop29b_expand_env_home_always_expands(
1053            suffix in "/[a-z0-9/_-]{0,30}",
1054        ) {
1055            let home_val = "/home/proptest-user";
1056            let input = format!("$HOME{suffix}");
1057            let result = WorkspaceRegistry::expand_env_with(&input, |name| {
1058                if name == "HOME" {
1059                    Some(home_val.to_string())
1060                } else {
1061                    None
1062                }
1063            });
1064            prop_assert!(
1065                result.starts_with(home_val),
1066                "expected result to start with HOME value: input={input:?}, result={result:?}"
1067            );
1068        }
1069
1070        // -----------------------------------------------------------------------
1071        // Property 31: glob filter — name starts with "foo" iff matched by "foo*" (Req 6.7)
1072        // -----------------------------------------------------------------------
1073
1074        #[test]
1075        fn prop31_glob_filter_foo_star(
1076            name in "[a-z]{1,20}",
1077        ) {
1078            let dir = std::env::temp_dir();
1079            let ws = ResolvedWorkspace {
1080                canonical_path: dir.clone(),
1081                name: name.clone(),
1082                team: None,
1083                tags: vec![],
1084                status: WorkspaceStatus::Unavailable("test".to_string()),
1085            };
1086            let reg = WorkspaceRegistry {
1087                entries: vec![],
1088                path: PathBuf::from("/tmp/test.toml"),
1089                last_loaded: Instant::now(),
1090            };
1091            let resolved = vec![ws];
1092            let matched = reg.filter("foo*", &resolved);
1093
1094            let starts_with_foo = name.starts_with("foo");
1095            // The canonical_path is /tmp (or similar) — very unlikely to start with
1096            // "foo", but we only care about the name match in this property.
1097            // We need to isolate the name match.  Re-run the glob against name only.
1098            let name_matches = glob_match("foo*", &name);
1099            prop_assert_eq!(starts_with_foo, name_matches, "glob matching inconsistency");
1100            // The workspace should be in matched iff name matches (path is /tmp, not foo).
1101            prop_assert_eq!(name_matches, !matched.is_empty(), "filter result inconsistency");
1102        }
1103    }
1104
1105    // -----------------------------------------------------------------------
1106    // Arbitrary generator for WorkspaceEntry
1107    // -----------------------------------------------------------------------
1108
1109    fn arb_workspace_entry() -> impl Strategy<Value = WorkspaceEntry> {
1110        (
1111            // path: must not be empty for serialisation to be valid TOML
1112            "/[a-z][a-z0-9/_-]{0,20}",
1113            prop::option::of("[a-z][a-z0-9-]{0,15}"),
1114            prop::option::of("[a-z][a-z0-9-]{0,10}"),
1115            prop::option::of(prop::collection::vec("[a-z][a-z0-9-]{0,8}", 0..=4)),
1116        )
1117            .prop_map(|(path, name, team, tags)| WorkspaceEntry {
1118                path,
1119                name,
1120                team,
1121                tags,
1122                policy_overrides: None, // toml::Value isn't Arbitrary; skip for round-trip
1123            })
1124    }
1125}