1use std::path::PathBuf;
10
11use crate::config::Config;
12use crate::name::{AgentId, ProfileName};
13use crate::repo::Discovery;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ResolutionSource {
18 Explicit,
19 RepositoryAgent,
20 RepositoryDefault,
21 GlobalDefault,
22 None,
23}
24
25impl ResolutionSource {
26 pub fn label(self) -> &'static str {
28 match self {
29 ResolutionSource::Explicit => "explicit",
30 ResolutionSource::RepositoryAgent => "repository agent mapping",
31 ResolutionSource::RepositoryDefault => "repository mapping",
32 ResolutionSource::GlobalDefault => "global default",
33 ResolutionSource::None => "none",
34 }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Resolution {
41 pub agent: AgentId,
42 pub profile: Option<ProfileName>,
43 pub source: ResolutionSource,
44 pub repository: Option<PathBuf>,
45}
46
47pub fn resolve(
50 agent: AgentId,
51 explicit: Option<ProfileName>,
52 config: &Config,
53 discovery: &Discovery,
54) -> Resolution {
55 let repository = match discovery {
56 Discovery::Repository(root) => Some(root.clone()),
57 Discovery::NotInRepository => None,
58 };
59 let mapping = repository.as_deref().and_then(|root| config.mapping(root));
60 let (profile, source) = if let Some(profile) = explicit {
61 (Some(profile), ResolutionSource::Explicit)
62 } else if let Some(profile) = mapping.and_then(|mapping| mapping.agents.get(&agent)) {
63 (Some(profile.clone()), ResolutionSource::RepositoryAgent)
64 } else if let Some(profile) = mapping.and_then(|mapping| mapping.profile.as_ref()) {
65 (Some(profile.clone()), ResolutionSource::RepositoryDefault)
66 } else if let Some(profile) = config.default_profile() {
67 (Some(profile.clone()), ResolutionSource::GlobalDefault)
68 } else {
69 (None, ResolutionSource::None)
70 };
71 Resolution { agent, profile, source, repository }
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77 use crate::config::AppRoot;
78 use crate::name::Platform;
79 use std::path::Path;
80
81 fn profile(name: &str) -> ProfileName {
82 ProfileName::parse(name, Platform::host()).unwrap()
83 }
84
85 fn agent(id: &str) -> AgentId {
86 AgentId::parse(id).unwrap()
87 }
88
89 fn root(name: &str) -> PathBuf {
91 if cfg!(windows) {
92 PathBuf::from(format!(r"C:\{name}"))
93 } else {
94 PathBuf::from(format!("/{name}"))
95 }
96 }
97
98 fn config(text: &str) -> Config {
99 let dir = tempfile::tempdir().unwrap();
100 std::fs::write(dir.path().join("config.toml"), text).unwrap();
101 Config::load(&AppRoot::from_path(dir.path().to_path_buf())).unwrap()
102 }
103
104 fn key(path: &Path) -> String {
105 toml_edit::Key::new(path.to_str().unwrap()).display_repr().into_owned()
106 }
107
108 fn resolved(
109 config: &Config,
110 explicit: Option<&str>,
111 discovery: &Discovery,
112 id: &str,
113 ) -> (Option<String>, ResolutionSource) {
114 let resolution = resolve(agent(id), explicit.map(profile), config, discovery);
115 (resolution.profile.map(|profile| profile.to_string()), resolution.source)
116 }
117
118 #[test]
119 fn each_precedence_step_wins_over_the_ones_below_it() {
120 let repo = root("acme");
121 let full = config(&format!(
122 "default_profile = \"global\"\n[repositories.{}]\nprofile = \"repo\"\nagents = {{ claude = \"agent\" }}\n",
123 key(&repo)
124 ));
125 let inside = Discovery::Repository(repo.clone());
126 assert_eq!(
127 resolved(&full, Some("explicit"), &inside, "claude"),
128 (Some("explicit".to_owned()), ResolutionSource::Explicit)
129 );
130 assert_eq!(
131 resolved(&full, None, &inside, "claude"),
132 (Some("agent".to_owned()), ResolutionSource::RepositoryAgent)
133 );
134 assert_eq!(
135 resolved(&full, None, &inside, "codex"),
136 (Some("repo".to_owned()), ResolutionSource::RepositoryDefault)
137 );
138 let agents_only = config(&format!(
139 "default_profile = \"global\"\n[repositories.{}]\nagents = {{ claude = \"agent\" }}\n",
140 key(&repo)
141 ));
142 assert_eq!(
143 resolved(&agents_only, None, &inside, "codex"),
144 (Some("global".to_owned()), ResolutionSource::GlobalDefault)
145 );
146 assert_eq!(resolved(&config(""), None, &inside, "codex"), (None, ResolutionSource::None));
147 }
148
149 #[test]
150 fn a_mapping_applies_only_to_its_exact_root() {
151 let parent = root("acme");
152 let text = format!("[repositories.{}]\nprofile = \"work\"\n", key(&parent));
153 let config = config(&text);
154 let nested = Discovery::Repository(parent.join("vendor").join("lib"));
155 assert_eq!(resolved(&config, None, &nested, "claude"), (None, ResolutionSource::None));
156 let trailing = Discovery::Repository(PathBuf::from(format!(
157 "{}{}",
158 parent.display(),
159 std::path::MAIN_SEPARATOR
160 )));
161 assert_eq!(
162 resolved(&config, None, &trailing, "claude"),
163 (Some("work".to_owned()), ResolutionSource::RepositoryDefault)
164 );
165 }
166
167 #[test]
168 fn outside_a_repository_only_the_global_default_applies() {
169 let text = format!(
170 "default_profile = \"global\"\n[repositories.{}]\nprofile = \"work\"\n",
171 key(&root("acme"))
172 );
173 let outside = Discovery::NotInRepository;
174 assert_eq!(
175 resolved(&config(&text), None, &outside, "claude"),
176 (Some("global".to_owned()), ResolutionSource::GlobalDefault)
177 );
178 let text = format!("[repositories.{}]\nprofile = \"work\"\n", key(&root("acme")));
179 assert_eq!(
180 resolved(&config(&text), None, &outside, "claude"),
181 (None, ResolutionSource::None)
182 );
183 }
184
185 #[test]
186 fn the_repository_is_reported_for_every_source() {
187 let repo = root("acme");
188 let text = format!(
189 "default_profile = \"global\"\n[repositories.{}]\nprofile = \"repo\"\nagents = {{ claude = \"agent\" }}\n",
190 key(&repo)
191 );
192 let full = config(&text);
193 let inside = Discovery::Repository(repo.clone());
194 let other = Discovery::Repository(root("other"));
195 for (explicit, discovery, id, source) in [
196 (Some("x"), &inside, "claude", ResolutionSource::Explicit),
197 (None, &inside, "claude", ResolutionSource::RepositoryAgent),
198 (None, &inside, "codex", ResolutionSource::RepositoryDefault),
199 (None, &other, "codex", ResolutionSource::GlobalDefault),
200 ] {
201 let resolution = resolve(agent(id), explicit.map(profile), &full, discovery);
202 assert_eq!(resolution.source, source);
203 let expected = match discovery {
204 Discovery::Repository(root) => Some(root.clone()),
205 Discovery::NotInRepository => None,
206 };
207 assert_eq!(resolution.repository, expected, "{source:?}");
208 }
209 let none = resolve(agent("codex"), None, &config(""), &other);
210 assert_eq!((none.source, none.repository), (ResolutionSource::None, Some(root("other"))));
211 let outside =
212 resolve(agent("codex"), Some(profile("x")), &full, &Discovery::NotInRepository);
213 assert_eq!(outside.repository, None);
214 }
215}