Skip to main content

cmx_core/
context.rs

1use anyhow::Result;
2use std::collections::BTreeMap;
3
4use crate::config;
5use crate::gateway::{Clock, Filesystem, GitClient, LlmClient};
6use crate::lockfile;
7use crate::paths::ConfigPaths;
8use crate::types::{InstallScope, LockFile, SourcesFile};
9
10/// Bundles all I/O gateway dependencies for a command invocation.
11///
12/// Production code constructs one `AppContext` in `main` with real
13/// implementations and passes it down.  Tests construct it with fakes.
14pub struct AppContext<'a> {
15    pub fs: &'a dyn Filesystem,
16    pub git: &'a dyn GitClient,
17    pub clock: &'a dyn Clock,
18    pub paths: &'a ConfigPaths,
19    pub llm: Option<&'a dyn LlmClient>,
20}
21
22impl<'a> AppContext<'a> {
23    /// Return a copy of this context with `paths` replaced.
24    pub fn with_paths<'b>(&self, paths: &'b ConfigPaths) -> AppContext<'b>
25    where
26        'a: 'b,
27    {
28        AppContext {
29            fs: self.fs,
30            git: self.git,
31            clock: self.clock,
32            paths,
33            llm: self.llm,
34        }
35    }
36
37    /// Return a copy of this context with `llm` set to `Some(llm)`.
38    #[cfg(feature = "llm")]
39    pub fn with_llm<'b>(&self, llm: &'b dyn LlmClient) -> AppContext<'b>
40    where
41        'a: 'b,
42    {
43        AppContext {
44            fs: self.fs,
45            git: self.git,
46            clock: self.clock,
47            paths: self.paths,
48            llm: Some(llm),
49        }
50    }
51}
52
53/// Pre-loaded configuration state — sources file plus both lock files.
54///
55/// Consolidates the repeated pattern of loading sources + both lock files at
56/// the start of every command into a single I/O step.  Command modules call
57/// [`LoadedState::load`] once and then pass the plain data to pure logic
58/// functions that accept no `&AppContext`.
59pub struct LoadedState {
60    pub sources: SourcesFile,
61    pub locks: BTreeMap<InstallScope, LockFile>,
62}
63
64impl LoadedState {
65    pub fn load(ctx: &AppContext<'_>) -> Result<Self> {
66        let sources = config::load_sources(ctx.fs, ctx.paths)?;
67        let locks = lockfile::load_both(ctx.fs, ctx.paths)?;
68        Ok(Self { sources, locks })
69    }
70
71    /// Return a reference to the lock file for the given scope.
72    pub fn lock(&self, scope: InstallScope) -> &LockFile {
73        &self.locks[&scope]
74    }
75
76    /// Iterate over all scopes and their lock files (global first, then local).
77    pub fn scopes(&self) -> impl Iterator<Item = (InstallScope, &LockFile)> {
78        self.locks.iter().map(|(s, l)| (*s, l))
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::gateway::fakes::{FakeClock, FakeFilesystem, FakeGitClient};
86    use crate::lockfile;
87    use crate::test_support::{make_lock_entry_builder, test_paths};
88    use crate::types::{ArtifactKind, InstallScope, LockFile};
89    use chrono::Utc;
90    use std::collections::BTreeMap;
91
92    fn make_ctx<'a>(
93        fs: &'a FakeFilesystem,
94        git: &'a FakeGitClient,
95        clock: &'a FakeClock,
96        paths: &'a ConfigPaths,
97    ) -> AppContext<'a> {
98        AppContext {
99            fs,
100            git,
101            clock,
102            paths,
103            llm: None,
104        }
105    }
106
107    #[test]
108    fn loaded_state_load_empty_fs_returns_defaults() {
109        let paths = test_paths();
110        let fs = FakeFilesystem::new();
111        let git = FakeGitClient::new();
112        let clock = FakeClock::at(Utc::now());
113        let ctx = make_ctx(&fs, &git, &clock, &paths);
114
115        let state = LoadedState::load(&ctx).unwrap();
116        assert!(state.sources.sources.is_empty());
117        assert!(state.lock(InstallScope::Global).packages.is_empty());
118        assert!(state.lock(InstallScope::Local).packages.is_empty());
119    }
120
121    #[test]
122    fn loaded_state_lock_returns_scope_lockfile() {
123        let paths = test_paths();
124        let fs = FakeFilesystem::new();
125        let git = FakeGitClient::new();
126        let clock = FakeClock::at(Utc::now());
127
128        let entry = make_lock_entry_builder(ArtifactKind::Agent, "myrepo", "agents/my-agent.md");
129        let mut packages = BTreeMap::new();
130        packages.insert("my-agent".to_string(), entry);
131        let lock = LockFile {
132            version: 1,
133            packages,
134        };
135        lockfile::save(&lock, InstallScope::Global, &fs, &paths).unwrap();
136
137        let ctx = make_ctx(&fs, &git, &clock, &paths);
138        let state = LoadedState::load(&ctx).unwrap();
139
140        assert!(state.lock(InstallScope::Global).packages.contains_key("my-agent"));
141        assert!(state.lock(InstallScope::Local).packages.is_empty());
142    }
143
144    #[test]
145    fn loaded_state_scopes_global_first() {
146        let paths = test_paths();
147        let fs = FakeFilesystem::new();
148        let git = FakeGitClient::new();
149        let clock = FakeClock::at(Utc::now());
150        let ctx = make_ctx(&fs, &git, &clock, &paths);
151
152        let state = LoadedState::load(&ctx).unwrap();
153        let scopes: Vec<InstallScope> = state.scopes().map(|(s, _)| s).collect();
154        assert_eq!(scopes, vec![InstallScope::Global, InstallScope::Local]);
155    }
156}