Skip to main content

agentlink_domain/
config.rs

1//! Workspace configuration (`.agentlink/config.toml`).
2
3use serde::{Deserialize, Serialize};
4
5/// Current on-disk format version.
6pub const CONFIG_VERSION: u32 = 1;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(deny_unknown_fields)]
10pub struct Config {
11    pub version: u32,
12
13    /// Providers to serve. Omit to serve every provider agentlink knows about,
14    /// including ones added by future releases.
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub providers: Option<Vec<String>>,
17
18    #[serde(default)]
19    pub gitignore: Gitignore,
20}
21
22impl Default for Config {
23    fn default() -> Self {
24        Self {
25            version: CONFIG_VERSION,
26            providers: None,
27            gitignore: Gitignore::default(),
28        }
29    }
30}
31
32/// How agentlink treats the paths it materialises with respect to git.
33///
34/// The default reflects the recommended posture: commit the canonical layout,
35/// and let every developer's `agentlink apply` recreate their own links. Symlinks
36/// committed to a repository silently degrade into plain text files for
37/// teammates on Windows without `core.symlinks`, and junctions cannot be
38/// represented in git at all. See `docs/adr/0005-git-posture.md`.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(default, deny_unknown_fields)]
41pub struct Gitignore {
42    /// Whether agentlink maintains a marked block in `.gitignore` listing the
43    /// provider paths it materialises.
44    pub manage: bool,
45}
46
47impl Default for Gitignore {
48    fn default() -> Self {
49        Self { manage: true }
50    }
51}
52
53#[derive(Debug, thiserror::Error)]
54pub enum ConfigError {
55    #[error("could not parse .agentlink/config.toml: {0}")]
56    Syntax(String),
57    #[error(
58        "unsupported config version {0} (this build understands {CONFIG_VERSION}); \
59         upgrade agentlink"
60    )]
61    Version(u32),
62}
63
64impl Config {
65    pub fn parse(text: &str) -> Result<Self, ConfigError> {
66        let config: Config =
67            toml::from_str(text).map_err(|err| ConfigError::Syntax(err.to_string()))?;
68        if config.version != CONFIG_VERSION {
69            return Err(ConfigError::Version(config.version));
70        }
71        Ok(config)
72    }
73
74    pub fn render(&self) -> String {
75        let body = toml::to_string_pretty(self).expect("config is always serialisable");
76        format!(
77            "# agentlink configuration — https://github.com/fialhosoft/agentlink\n\
78             #\n\
79             # `providers` lists the agents to serve. Remove the key entirely to\n\
80             # serve every agent agentlink knows about, including ones added by\n\
81             # future releases.\n\n{body}"
82        )
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn round_trips_through_toml() {
92        let config = Config {
93            version: CONFIG_VERSION,
94            providers: Some(vec!["claude-code".into(), "cursor".into()]),
95            gitignore: Gitignore { manage: false },
96        };
97        assert_eq!(Config::parse(&config.render()).unwrap(), config);
98    }
99
100    #[test]
101    fn defaults_round_trip() {
102        let config = Config::default();
103        assert_eq!(Config::parse(&config.render()).unwrap(), config);
104    }
105
106    #[test]
107    fn an_omitted_provider_list_means_every_provider() {
108        let config = Config::parse("version = 1").unwrap();
109        assert_eq!(config.providers, None);
110        assert!(config.gitignore.manage);
111    }
112
113    #[test]
114    fn rejects_unknown_keys_rather_than_ignoring_a_typo() {
115        let err = Config::parse("version = 1\nproviderz = []").unwrap_err();
116        assert!(matches!(err, ConfigError::Syntax(_)));
117    }
118
119    #[test]
120    fn rejects_a_config_written_by_a_newer_agentlink() {
121        assert!(matches!(
122            Config::parse("version = 42").unwrap_err(),
123            ConfigError::Version(42)
124        ));
125    }
126}