Skip to main content

agent_profile/
resolve.rs

1// Copyright (c) 2026 Costas Kirgoussios
2//
3// This software is licensed under the PolyForm Noncommercial License 1.0.0.
4// You may obtain a copy of the License at:
5// https://polyformproject.org/licenses/noncommercial/1.0.0/
6
7//! The single profile resolver shared by every command (spec §12, SP3 design §4.3).
8
9use std::path::PathBuf;
10
11use crate::config::Config;
12use crate::name::{AgentId, ProfileName};
13use crate::repo::Discovery;
14
15/// Where a resolved profile came from (spec §12).
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ResolutionSource {
18    Explicit,
19    RepositoryAgent,
20    RepositoryDefault,
21    GlobalDefault,
22    None,
23}
24
25impl ResolutionSource {
26    /// The lower-case label used in human output.
27    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/// The canonical resolution object (spec §12).
39#[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
47/// Pure: callers run discovery first. A mapping applies only when its key is component-equal to the
48/// discovered root, so a nested repository never inherits its parent's mapping (design D5).
49pub 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    /// `/r` on Unix and `C:\r` on Windows, so the key is absolute on the host.
90    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}