openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
//! Manifest parsing and path expansion for the configuration-plane monitor.
//!
//! The manifest is embedded into the binary via `include_str!` at compile
//! time. Tests (and only tests, gated behind `insecure-test-keys`) may
//! override the source via `OPENLATCH_INVENTORY_MANIFEST_PATH`.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

/// Top-level manifest document. One of these is parsed from
/// `manifests/agent_configs.toml`.
#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
    #[serde(default, rename = "agent")]
    pub agents: Vec<AgentManifest>,
}

/// Per-agent manifest section.
#[derive(Debug, Clone, Deserialize)]
pub struct AgentManifest {
    pub name: String,
    #[serde(default, rename = "path")]
    pub paths: Vec<AgentPath>,
    #[serde(default)]
    pub exclude: Option<ExcludeBlock>,
}

/// Precedence tier the cloud's routing engine uses to resolve same-named
/// MCP servers (and other layered config) discovered on the same machine.
/// Order: `Enterprise` > `Personal` > `Project` > `Local` — most-specific
/// scope wins. Serialised to lowercase wire strings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ConfigScope {
    Enterprise,
    Personal,
    Project,
    Local,
}

impl ConfigScope {
    pub fn as_str(self) -> &'static str {
        match self {
            ConfigScope::Enterprise => "enterprise",
            ConfigScope::Personal => "personal",
            ConfigScope::Project => "project",
            ConfigScope::Local => "local",
        }
    }

    /// Whether this scope's manifest entries are rooted at the active
    /// project (cwd) rather than the user / system home. The monitor uses
    /// this to gate user-scope walks vs. project-scope walks.
    pub fn is_project_rooted(self) -> bool {
        matches!(self, ConfigScope::Project | ConfigScope::Local)
    }
}

/// One declared watch entry. The combination of `paths` / `paths_glob` /
/// `paths_relative` / `paths_glob_relative` / `json_slice_paths` is governed
/// by the `watch_strategy` discriminant; `manifest::validate` checks the
/// invariants at load time.
#[derive(Debug, Clone, Deserialize)]
pub struct AgentPath {
    pub kind: String,
    #[serde(default)]
    pub scope: Option<ConfigScope>,
    #[serde(default)]
    pub paths: Vec<String>,
    #[serde(default)]
    pub paths_relative: Vec<String>,
    #[serde(default)]
    pub paths_glob: Option<String>,
    #[serde(default)]
    pub paths_glob_relative: Vec<String>,
    #[serde(default)]
    pub json_slice_paths: Vec<JsonSlicePath>,
    pub watch_strategy: WatchStrategy,
    #[serde(default)]
    pub slice_kind_subpath: bool,
}

impl AgentPath {
    /// Whether this entry is anchored at the active project root (cwd) and
    /// therefore must be processed by `run_project_scope_scan` instead of
    /// the user-scope walk. Currently true for `project` + `local` scopes.
    pub fn is_project_scoped(&self) -> bool {
        self.scope.is_some_and(|s| s.is_project_rooted())
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct JsonSlicePath {
    pub path: String,
    /// RFC 6901-style JSON Pointer (e.g. `/mcpServers`).
    pub json_pointer: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ExcludeBlock {
    #[serde(default)]
    pub patterns: Vec<String>,
}

/// Watch strategy discriminant for an `[[agent.path]]` block.
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WatchStrategy {
    /// Watch a single fixed file.
    ExactFile,
    /// Watch a single fixed file and hash a JSON Pointer slice of it.
    ExactFileWithSlice,
    /// Watch a directory glob; one path per matching file.
    Glob,
    /// Watch both a fixed file AND a directory glob.
    ExactFileAndGlob,
}

/// Errors raised while loading or parsing the manifest.
#[derive(thiserror::Error, Debug)]
pub enum ManifestError {
    #[error("manifest read failed at {0}: {1}")]
    ReadFailed(String, std::io::Error),
    #[error("manifest parse failed at {0}: {1}")]
    ParseFailed(String, String),
}

/// Errors raised while expanding a manifest path template.
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
pub enum PathExpansionError {
    #[error("home directory not found")]
    HomeNotFound,
    #[error("app-data directory not found")]
    AppDataNotFound,
    #[error("project-scope path used without a project root")]
    ProjectScopeWithoutRoot,
    #[error("unknown variable: {0}")]
    UnknownVariable(String),
    #[error("env var missing: {0}")]
    EnvVarMissing(String),
    #[error("unterminated variable expansion in: {0}")]
    UnterminatedVariable(String),
}

/// Source of the embedded manifest TOML.
const EMBEDDED_MANIFEST: &str = include_str!("../../../manifests/agent_configs.toml");

/// The parsed embedded manifest, computed once and cloned on each request so
/// production callers can re-load (e.g. on a `manual rescan`) without paying
/// the toml-parser cost again.
static EMBEDDED_PARSED: std::sync::LazyLock<Result<Manifest, String>> =
    std::sync::LazyLock::new(|| {
        toml::from_str::<Manifest>(EMBEDDED_MANIFEST).map_err(|e| e.to_string())
    });

/// Load the manifest. In production this returns the embedded copy parsed at
/// startup. Test builds (with the `insecure-test-keys` feature OR running
/// under `cfg(test)`) honour the `OPENLATCH_INVENTORY_MANIFEST_PATH` env var
/// for fixture overrides.
pub fn load_embedded() -> Result<Manifest, ManifestError> {
    #[cfg(any(test, feature = "insecure-test-keys"))]
    if let Ok(path) = std::env::var("OPENLATCH_INVENTORY_MANIFEST_PATH") {
        if !path.is_empty() {
            let raw = std::fs::read_to_string(&path)
                .map_err(|e| ManifestError::ReadFailed(path.clone(), e))?;
            return parse_str(&raw, &path);
        }
    }
    EMBEDDED_PARSED
        .clone()
        .map_err(|msg| ManifestError::ParseFailed("embedded".to_string(), msg))
}

#[cfg(any(test, feature = "insecure-test-keys"))]
fn parse_str(raw: &str, source: &str) -> Result<Manifest, ManifestError> {
    toml::from_str::<Manifest>(raw)
        .map_err(|e| ManifestError::ParseFailed(source.to_string(), e.to_string()))
}

/// Expand a manifest path template. Supports the nine variables documented in
/// `manifests/agent_configs.toml`:
///
/// - `${CLAUDE_CONFIG_DIR}` — `hooks::claude_code::config_dir`: the relocated
///   directory when `$CLAUDE_CONFIG_DIR` is set, else `~/.claude`.
/// - `${CLAUDE_STATE_DIR}` — `hooks::claude_code::state_dir`: the parent of
///   `.claude.json`, which is that same relocated directory when the variable
///   is set but `$HOME` — one component shallower — when it is not.
/// - `${HOME}` — `dirs::home_dir`
/// - `${APPDATA}` — `dirs::data_dir`
/// - `${VSCODE_USER}` — `dirs::config_dir().join("Code/User")`
/// - `${OPENLATCH_DIR}` — `crate::config::openlatch_dir`
/// - `${SYSTEM_CLAUDE_DIR}` — per-OS Claude Code system / org-managed config
///   dir: `/etc/claude-code` (Linux), `/Library/Application Support/Claude`
///   (macOS), `%ProgramData%\Claude` (Windows). Used by enterprise-scope paths.
/// - `${PROJECT}` — only valid when `project_root` is `Some`; otherwise
///   returns `ProjectScopeWithoutRoot`.
/// - `${env:VAR}` — `std::env::var` lookup.
///
/// The two Claude variables are **resolvers, not environment reads**: they have
/// a defined answer with `$CLAUDE_CONFIG_DIR` unset, where the raw
/// `${env:CLAUDE_CONFIG_DIR}` form would fail the whole template with
/// `EnvVarMissing`. Every Claude Code path in the manifest must go through them.
/// Anchoring one on `${HOME}/.claude` instead is how this subsystem shipped a
/// release watching `~/.claude` while hook installation, boundary wiring and
/// identity all followed the user's relocated directory — the drift and tamper
/// perimeter covering a directory the agent no longer reads.
///
/// Unknown variables return `UnknownVariable`. Unterminated `${...` blocks
/// return `UnterminatedVariable`.
pub fn expand_path(
    input: &str,
    project_root: Option<&Path>,
) -> Result<PathBuf, PathExpansionError> {
    let mut out = String::with_capacity(input.len());
    let mut chars = input.chars().peekable();
    while let Some(c) = chars.next() {
        if c == '$' && chars.peek() == Some(&'{') {
            chars.next();
            let mut var = String::new();
            let mut closed = false;
            for c in chars.by_ref() {
                if c == '}' {
                    closed = true;
                    break;
                }
                var.push(c);
            }
            if !closed {
                return Err(PathExpansionError::UnterminatedVariable(input.to_string()));
            }
            out.push_str(&resolve_var(&var, project_root)?);
        } else {
            out.push(c);
        }
    }
    Ok(PathBuf::from(out))
}

fn resolve_var(name: &str, project_root: Option<&Path>) -> Result<String, PathExpansionError> {
    match name {
        "CLAUDE_CONFIG_DIR" => crate::hooks::claude_code::config_dir()
            .map(|p| p.display().to_string())
            .ok_or(PathExpansionError::HomeNotFound),
        "CLAUDE_STATE_DIR" => crate::hooks::claude_code::state_dir()
            .map(|p| p.display().to_string())
            .ok_or(PathExpansionError::HomeNotFound),
        "HOME" => dirs::home_dir()
            .map(|p| p.display().to_string())
            .ok_or(PathExpansionError::HomeNotFound),
        "APPDATA" => dirs::data_dir()
            .map(|p| p.display().to_string())
            .ok_or(PathExpansionError::AppDataNotFound),
        "VSCODE_USER" => dirs::config_dir()
            .map(|p| p.join("Code").join("User").display().to_string())
            .ok_or(PathExpansionError::AppDataNotFound),
        "OPENLATCH_DIR" => Ok(crate::config::openlatch_dir().display().to_string()),
        "SYSTEM_CLAUDE_DIR" => Ok(system_claude_dir()),
        "PROJECT" => project_root
            .map(|p| p.display().to_string())
            .ok_or(PathExpansionError::ProjectScopeWithoutRoot),
        other if other.starts_with("env:") => {
            let env_name = &other[4..];
            std::env::var(env_name).map_err(|_| PathExpansionError::EnvVarMissing(env_name.into()))
        }
        other => Err(PathExpansionError::UnknownVariable(other.into())),
    }
}

/// Resolve the platform-specific Claude Code system / org-managed config
/// directory. Returned as a string so it composes cleanly with the rest of
/// the manifest's template expansion. The directory is not required to
/// exist — non-existent paths are skipped downstream like any other.
fn system_claude_dir() -> String {
    #[cfg(target_os = "linux")]
    {
        return "/etc/claude-code".to_string();
    }
    #[cfg(target_os = "macos")]
    {
        return "/Library/Application Support/Claude".to_string();
    }
    #[cfg(target_os = "windows")]
    {
        let base = std::env::var("ProgramData").unwrap_or_else(|_| "C:\\ProgramData".to_string());
        return format!("{base}\\Claude");
    }
    #[allow(unreachable_code)]
    String::new()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn embedded_manifest_parses() {
        let m = load_embedded().expect("embedded manifest must parse");
        assert!(!m.agents.is_empty(), "embedded manifest declares agents");
        let claude = m
            .agents
            .iter()
            .find(|a| a.name == "claude-code")
            .expect("claude-code agent present");
        assert!(
            claude.paths.iter().any(|p| p.kind == "mcp"),
            "claude-code includes an mcp path entry"
        );
    }

    /// Every absolute template the `claude-code` agent declares, in declaration
    /// order: `paths`, `paths_glob`, `json_slice_paths`, and the exclude block.
    ///
    /// The `*_relative` fields are deliberately absent — those are anchored on
    /// a per-event project root, never on the user's config directory, so they
    /// have no business in a test about where that directory is.
    fn claude_code_absolute_templates() -> Vec<String> {
        let m = load_embedded().expect("embedded manifest must parse");
        let agent = m
            .agents
            .iter()
            .find(|a| a.name == "claude-code")
            .expect("claude-code agent present");

        let mut out = Vec::new();
        for ap in &agent.paths {
            out.extend(ap.paths.iter().cloned());
            out.extend(ap.paths_glob.iter().cloned());
            out.extend(ap.json_slice_paths.iter().map(|s| s.path.clone()));
        }
        out.extend(
            agent
                .exclude
                .iter()
                .flat_map(|e| e.patterns.iter().cloned()),
        );
        out
    }

    /// **The regression gate for the fourth resolver.**
    ///
    /// With `$CLAUDE_CONFIG_DIR` pointed somewhere real, not one absolute
    /// template in the embedded manifest may still resolve into the default
    /// `~/.claude` — that is the whole failure this test exists to catch. The
    /// config monitor used to reach its paths through `${HOME}/.claude/...`,
    /// which `dirs::home_dir()` answers without ever consulting the variable,
    /// so a user who relocated their Claude config had hooks, boundary wiring
    /// and identity follow the new directory while drift and tamper monitoring
    /// kept watching the old one.
    ///
    /// It fails on *any* future `${HOME}/.claude` entry, which is the point: the
    /// manifest is data, so nothing in the type system stops the next one.
    ///
    /// The temp directory is the custom path on purpose — a sandbox that sets
    /// `$CLAUDE_CONFIG_DIR` to `$HOME/.claude` proves nothing, since a resolver
    /// that ignores the variable entirely lands on the same answer.
    #[test]
    fn embedded_manifest_follows_a_relocated_config_dir() {
        let _lock = crate::daemon::identity::ENV_LOCK.blocking_lock();
        let env = crate::daemon::identity::test_support::EnvGuard::clear();
        let relocated = tempfile::tempdir().expect("temp dir");
        env.set("CLAUDE_CONFIG_DIR", relocated.path());

        let home = dirs::home_dir().expect("home dir");
        let default_config_dir = home.join(".claude");
        let default_state_file = home.join(".claude.json");

        let templates = claude_code_absolute_templates();
        assert!(
            !templates.is_empty(),
            "the sweep must actually see the manifest's templates"
        );

        let mut under_relocated = 0usize;
        for template in &templates {
            let expanded = expand_path(template, None)
                .unwrap_or_else(|e| panic!("{template} must expand: {e}"));
            assert!(
                !expanded.starts_with(&default_config_dir),
                "{template} resolved to {} — inside the default config directory \
                 while $CLAUDE_CONFIG_DIR points at {}",
                expanded.display(),
                relocated.path().display()
            );
            assert_ne!(
                expanded,
                default_state_file,
                "{template} resolved to the default state file while \
                 $CLAUDE_CONFIG_DIR points at {}",
                relocated.path().display()
            );
            if expanded.starts_with(relocated.path()) {
                under_relocated += 1;
            }
        }

        // Everything except the one enterprise-scope `${SYSTEM_CLAUDE_DIR}`
        // entry, which is org-managed and rightly ignores the user's variable.
        assert_eq!(
            under_relocated,
            templates.len() - 1,
            "exactly one template (the SYSTEM_CLAUDE_DIR one) may sit outside \
             the relocated directory"
        );
    }

    /// `${CLAUDE_CONFIG_DIR}` and `${CLAUDE_STATE_DIR}` differ by exactly one
    /// component when the variable is unset and collapse onto the same
    /// directory when it is set — the asymmetry `provider_account` documents,
    /// asserted here because the manifest is the second consumer of it.
    #[test]
    fn claude_dir_variables_track_the_env_var() {
        let _lock = crate::daemon::identity::ENV_LOCK.blocking_lock();
        let env = crate::daemon::identity::test_support::EnvGuard::clear();
        let home = dirs::home_dir().expect("home dir");

        // Unset: settings live one level below the state file.
        assert_eq!(
            expand_path("${CLAUDE_CONFIG_DIR}/settings.json", None).unwrap(),
            home.join(".claude").join("settings.json")
        );
        assert_eq!(
            expand_path("${CLAUDE_STATE_DIR}/.claude.json", None).unwrap(),
            home.join(".claude.json")
        );

        // Relocated: both land in the same directory.
        let relocated = tempfile::tempdir().expect("temp dir");
        env.set("CLAUDE_CONFIG_DIR", relocated.path());
        assert_eq!(
            expand_path("${CLAUDE_CONFIG_DIR}/settings.json", None).unwrap(),
            relocated.path().join("settings.json")
        );
        assert_eq!(
            expand_path("${CLAUDE_STATE_DIR}/.claude.json", None).unwrap(),
            relocated.path().join(".claude.json")
        );

        // Empty reads as unset, matching every other reader of the variable —
        // an exported-but-blank value would otherwise anchor the whole
        // monitoring perimeter on the daemon's working directory.
        env.set("CLAUDE_CONFIG_DIR", "");
        assert_eq!(
            expand_path("${CLAUDE_CONFIG_DIR}/settings.json", None).unwrap(),
            home.join(".claude").join("settings.json")
        );
    }

    /// The bare form is a resolver, not an env read: `${env:CLAUDE_CONFIG_DIR}`
    /// fails the whole template when the variable is unset, which is why the
    /// manifest cannot use it for a path that must also work by default.
    #[test]
    fn claude_config_dir_resolver_is_not_the_env_form() {
        let _lock = crate::daemon::identity::ENV_LOCK.blocking_lock();
        let _env = crate::daemon::identity::test_support::EnvGuard::clear();

        assert!(expand_path("${CLAUDE_CONFIG_DIR}/settings.json", None).is_ok());
        assert_eq!(
            expand_path("${env:CLAUDE_CONFIG_DIR}/settings.json", None).unwrap_err(),
            PathExpansionError::EnvVarMissing("CLAUDE_CONFIG_DIR".into())
        );
    }

    #[test]
    fn expand_path_home() {
        let p = expand_path("${HOME}/.claude/.mcp.json", None).unwrap();
        let home = dirs::home_dir().unwrap();
        assert_eq!(p, home.join(".claude").join(".mcp.json"));
    }

    #[test]
    fn expand_path_no_vars_returns_input() {
        let p = expand_path("/etc/openlatch/config.toml", None).unwrap();
        assert_eq!(p, PathBuf::from("/etc/openlatch/config.toml"));
    }

    #[test]
    fn expand_path_project_without_root_errors() {
        let err = expand_path("${PROJECT}/.claude/CLAUDE.md", None).unwrap_err();
        assert_eq!(err, PathExpansionError::ProjectScopeWithoutRoot);
    }

    #[test]
    fn expand_path_project_with_root() {
        let root = PathBuf::from("/tmp/myproject");
        let p = expand_path("${PROJECT}/.claude/CLAUDE.md", Some(&root)).unwrap();
        assert_eq!(p, root.join(".claude").join("CLAUDE.md"));
    }

    #[test]
    fn expand_path_env_var_present() {
        // SAFETY: tests that mutate process env are inherently global; pick a
        // unique name to avoid colliding with concurrent tests.
        let key = "OPENLATCH_TEST_EXPAND_PATH_PRESENT";
        std::env::set_var(key, "/x/y");
        let p = expand_path(&format!("${{env:{key}}}/file"), None).unwrap();
        std::env::remove_var(key);
        assert_eq!(p, PathBuf::from("/x/y/file"));
    }

    #[test]
    fn expand_path_env_var_missing() {
        let key = "OPENLATCH_TEST_EXPAND_PATH_MISSING";
        std::env::remove_var(key);
        let err = expand_path(&format!("${{env:{key}}}/file"), None).unwrap_err();
        assert_eq!(err, PathExpansionError::EnvVarMissing(key.into()));
    }

    #[test]
    fn expand_path_unknown_variable() {
        let err = expand_path("${NOPE}/file", None).unwrap_err();
        assert_eq!(err, PathExpansionError::UnknownVariable("NOPE".into()));
    }

    #[test]
    fn expand_path_unterminated_variable() {
        let err = expand_path("${HOME/file", None).unwrap_err();
        assert!(matches!(err, PathExpansionError::UnterminatedVariable(_)));
    }

    #[test]
    fn expand_path_openlatch_dir() {
        let p = expand_path("${OPENLATCH_DIR}/state.json", None).unwrap();
        let expected = crate::config::openlatch_dir().join("state.json");
        assert_eq!(p, expected);
    }

    #[test]
    fn parse_str_invalid_toml_reports_source() {
        let err = parse_str("not toml = =", "fixture").unwrap_err();
        match err {
            ManifestError::ParseFailed(source, _) => assert_eq!(source, "fixture"),
            _ => panic!("expected ParseFailed"),
        }
    }

    #[test]
    fn watch_strategy_round_trip() {
        let raw = r#"
            [[agent]]
            name = "demo"
            [[agent.path]]
            kind = "mcp"
            paths = ["/tmp/x.json"]
            watch_strategy = "exact_file"
        "#;
        let m: Manifest = toml::from_str(raw).unwrap();
        assert_eq!(
            m.agents[0].paths[0].watch_strategy,
            WatchStrategy::ExactFile
        );
    }

    #[test]
    fn config_scope_round_trip() {
        for (variant, wire) in [
            (ConfigScope::Enterprise, "enterprise"),
            (ConfigScope::Personal, "personal"),
            (ConfigScope::Project, "project"),
            (ConfigScope::Local, "local"),
        ] {
            let s = serde_json::to_string(&variant).unwrap();
            assert_eq!(s, format!("\"{wire}\""));
            let parsed: ConfigScope = serde_json::from_str(&s).unwrap();
            assert_eq!(parsed, variant);
            assert_eq!(variant.as_str(), wire);
        }
    }

    #[test]
    fn config_scope_parsed_from_toml() {
        let raw = r#"
            [[agent]]
            name = "demo"
            [[agent.path]]
            kind = "mcp"
            scope = "personal"
            paths = ["/tmp/x.json"]
            watch_strategy = "exact_file"
        "#;
        let m: Manifest = toml::from_str(raw).unwrap();
        assert_eq!(m.agents[0].paths[0].scope, Some(ConfigScope::Personal));
    }

    #[test]
    fn config_scope_rejects_unknown_variant() {
        let raw = r#"
            [[agent]]
            name = "demo"
            [[agent.path]]
            kind = "mcp"
            scope = "bogus"
            paths = ["/tmp/x.json"]
            watch_strategy = "exact_file"
        "#;
        let err = parse_str(raw, "fixture").unwrap_err();
        assert!(matches!(err, ManifestError::ParseFailed(_, _)));
    }

    #[test]
    fn expand_path_system_claude_dir() {
        let p = expand_path("${SYSTEM_CLAUDE_DIR}/mcp.json", None).unwrap();
        // Final component must be the literal we appended.
        assert_eq!(p.file_name().and_then(|s| s.to_str()), Some("mcp.json"));
        let parent = p
            .parent()
            .expect("system claude dir resolves to a non-empty path");
        #[cfg(target_os = "linux")]
        assert_eq!(parent, std::path::Path::new("/etc/claude-code"));
        #[cfg(target_os = "macos")]
        assert_eq!(
            parent,
            std::path::Path::new("/Library/Application Support/Claude")
        );
        #[cfg(target_os = "windows")]
        {
            // ProgramData base is env-resolved at runtime; just verify the
            // resolved dir ends with the `Claude` segment.
            assert_eq!(
                parent.file_name().and_then(|s| s.to_str()),
                Some("Claude"),
                "{}",
                parent.display()
            );
        }
    }
}