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
10pub 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 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 #[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
53pub 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 pub fn lock(&self, scope: InstallScope) -> &LockFile {
73 &self.locks[&scope]
74 }
75
76 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}