Skip to main content

agentstatedeveloper_core/
sidecar_config.rs

1//! Plan K t-007: `.asd/config.toml` reader for opt-in sidecar
2//! sharding. Today a single shape: per-package sharding via
3//! `[sidecar] shard_by = "package"`. Default is "class" (one file
4//! per conclusion class, current behavior).
5//!
6//! The file is intentionally tiny — most projects need no config.
7//! For projects where two teams edit the same conclusion class
8//! (e.g. both add Decisions in the same week), per-package sharding
9//! splits the file by `package_dir(file)` so independent inserts
10//! land in different shards and don't touch the same JSONL lines.
11//!
12//! Format:
13//!
14//! ```toml
15//! [sidecar]
16//! shard_by = "package"   # or "class" (default)
17//! ```
18//!
19//! Schema is intentionally forgiving — unknown keys and missing
20//! files both fall through to defaults. We don't want a typo in
21//! config to break the commit hook.
22
23use serde::Deserialize;
24use std::path::Path;
25
26/// How `asd conclusions export` lays out files under `.asd/conclusions/`.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum ShardBy {
29    /// One file per ConclusionClass: `.asd/conclusions/<stem>.jsonl`.
30    /// Default. Right for solo-dev and small-team projects.
31    #[default]
32    Class,
33    /// One file per (ConclusionClass, package_dir): `.asd/conclusions/
34    /// <stem>/<package-key>.jsonl`. Opt-in for monorepos where two
35    /// teams concurrently editing the same class would textually
36    /// conflict in the single-file layout.
37    Package,
38}
39
40/// Plan K t-008: size budget for the committed sidecar. Defaults
41/// chosen for AcmeFlow-scale projects: 1 MB total, 200 KB per
42/// shard file. Configurable per-project via `.asd/config.toml`.
43#[derive(Debug, Clone, Copy)]
44pub struct BudgetConfig {
45    /// Hard upper bound on the total size of `.asd/conclusions/`
46    /// (sum across all shard files). Default 1 MiB.
47    pub total_bytes: u64,
48    /// Hard upper bound on any single shard file's size. Default
49    /// 200 KiB.
50    pub per_shard_bytes: u64,
51}
52
53impl Default for BudgetConfig {
54    fn default() -> Self {
55        Self {
56            total_bytes: 1024 * 1024,
57            per_shard_bytes: 200 * 1024,
58        }
59    }
60}
61
62/// Top-level sidecar config.
63#[derive(Debug, Clone, Default)]
64pub struct SidecarConfig {
65    pub shard_by: ShardBy,
66    pub budget: BudgetConfig,
67}
68
69#[derive(Debug, Deserialize, Default)]
70struct ConfigFile {
71    #[serde(default)]
72    sidecar: Option<SidecarSection>,
73}
74
75#[derive(Debug, Deserialize, Default)]
76struct SidecarSection {
77    /// `"class"` (default) or `"package"`. Anything else falls back
78    /// to the default with a logged warning.
79    #[serde(default)]
80    shard_by: Option<String>,
81    /// Plan K t-008. Bytes for the whole `.asd/conclusions/` tree.
82    /// Omitted or invalid → default (1 MiB).
83    #[serde(default)]
84    budget_total_bytes: Option<u64>,
85    /// Plan K t-008. Bytes for any individual shard file. Omitted
86    /// or invalid → default (200 KiB).
87    #[serde(default)]
88    budget_per_shard_bytes: Option<u64>,
89}
90
91impl SidecarConfig {
92    /// Read `<project_root>/.asd/config.toml` if present. Missing
93    /// file, parse errors, or unknown values all fall through to
94    /// defaults — config is advisory, not load-bearing.
95    pub fn load_from_project(project_root: &Path) -> Self {
96        let path = project_root.join(".asd").join("config.toml");
97        Self::load_from_file(&path)
98    }
99
100    /// Lower-level: read an explicit config path. Test-friendly.
101    pub fn load_from_file(path: &Path) -> Self {
102        let raw = match std::fs::read_to_string(path) {
103            Ok(s) => s,
104            Err(_) => return Self::default(),
105        };
106        let parsed: ConfigFile = match toml::from_str(&raw) {
107            Ok(p) => p,
108            Err(_) => return Self::default(),
109        };
110        let sidecar = parsed.sidecar.unwrap_or_default();
111        let shard_by = sidecar
112            .shard_by
113            .as_deref()
114            .map(|s| match s {
115                "package" => ShardBy::Package,
116                "class" => ShardBy::Class,
117                _ => ShardBy::Class, // unknown → safe default
118            })
119            .unwrap_or_default();
120        let default_budget = BudgetConfig::default();
121        let budget = BudgetConfig {
122            total_bytes: sidecar
123                .budget_total_bytes
124                .unwrap_or(default_budget.total_bytes),
125            per_shard_bytes: sidecar
126                .budget_per_shard_bytes
127                .unwrap_or(default_budget.per_shard_bytes),
128        };
129        Self { shard_by, budget }
130    }
131}
132
133/// Sanitize a package directory path (`crates/foo/src`) into a
134/// filename-safe key (`crates--foo--src`). Empty string (root-
135/// level file with no parent directory) becomes `"_root"`.
136///
137/// Plan K t-007: stable transformation so the same package always
138/// produces the same shard filename across runs and machines.
139pub fn package_key_for_filename(package_dir: &str) -> String {
140    if package_dir.is_empty() {
141        return "_root".to_string();
142    }
143    package_dir.replace('/', "--").replace('\\', "--")
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use tempfile::tempdir;
150
151    #[test]
152    fn defaults_when_no_config_file() {
153        let tmp = tempdir().unwrap();
154        let cfg = SidecarConfig::load_from_project(tmp.path());
155        assert_eq!(cfg.shard_by, ShardBy::Class);
156    }
157
158    #[test]
159    fn reads_shard_by_package() {
160        let tmp = tempdir().unwrap();
161        let asd = tmp.path().join(".asd");
162        std::fs::create_dir_all(&asd).unwrap();
163        std::fs::write(
164            asd.join("config.toml"),
165            "[sidecar]\nshard_by = \"package\"\n",
166        )
167        .unwrap();
168        let cfg = SidecarConfig::load_from_project(tmp.path());
169        assert_eq!(cfg.shard_by, ShardBy::Package);
170    }
171
172    #[test]
173    fn reads_shard_by_class_explicitly() {
174        let tmp = tempdir().unwrap();
175        let asd = tmp.path().join(".asd");
176        std::fs::create_dir_all(&asd).unwrap();
177        std::fs::write(asd.join("config.toml"), "[sidecar]\nshard_by = \"class\"\n").unwrap();
178        let cfg = SidecarConfig::load_from_project(tmp.path());
179        assert_eq!(cfg.shard_by, ShardBy::Class);
180    }
181
182    #[test]
183    fn unknown_value_falls_back_to_default() {
184        let tmp = tempdir().unwrap();
185        let asd = tmp.path().join(".asd");
186        std::fs::create_dir_all(&asd).unwrap();
187        std::fs::write(
188            asd.join("config.toml"),
189            "[sidecar]\nshard_by = \"banana\"\n",
190        )
191        .unwrap();
192        let cfg = SidecarConfig::load_from_project(tmp.path());
193        assert_eq!(
194            cfg.shard_by,
195            ShardBy::Class,
196            "unknown shard_by value must fall back to default"
197        );
198    }
199
200    #[test]
201    fn malformed_toml_falls_back_to_default() {
202        let tmp = tempdir().unwrap();
203        let asd = tmp.path().join(".asd");
204        std::fs::create_dir_all(&asd).unwrap();
205        std::fs::write(asd.join("config.toml"), "this is not toml {{{").unwrap();
206        let cfg = SidecarConfig::load_from_project(tmp.path());
207        assert_eq!(cfg.shard_by, ShardBy::Class);
208    }
209
210    #[test]
211    fn reads_budget_overrides() {
212        let tmp = tempdir().unwrap();
213        let asd = tmp.path().join(".asd");
214        std::fs::create_dir_all(&asd).unwrap();
215        std::fs::write(
216            asd.join("config.toml"),
217            "[sidecar]\nbudget_total_bytes = 5000\nbudget_per_shard_bytes = 1000\n",
218        )
219        .unwrap();
220        let cfg = SidecarConfig::load_from_project(tmp.path());
221        assert_eq!(cfg.budget.total_bytes, 5000);
222        assert_eq!(cfg.budget.per_shard_bytes, 1000);
223    }
224
225    #[test]
226    fn budget_defaults_when_not_specified() {
227        let tmp = tempdir().unwrap();
228        let cfg = SidecarConfig::load_from_project(tmp.path());
229        assert_eq!(cfg.budget.total_bytes, 1024 * 1024);
230        assert_eq!(cfg.budget.per_shard_bytes, 200 * 1024);
231    }
232
233    #[test]
234    fn package_key_sanitizes_slashes() {
235        assert_eq!(
236            package_key_for_filename("crates/foo/src"),
237            "crates--foo--src"
238        );
239        assert_eq!(package_key_for_filename("src"), "src");
240        assert_eq!(package_key_for_filename(""), "_root");
241    }
242}