Skip to main content

linesmith_core/presets/
mod.rs

1//! Embedded `config.toml` presets per `docs/specs/config.md` §Presets.
2//!
3//! Presets are authored as TOML bodies shipped with the binary via
4//! `include_str!`. `linesmith presets list` prints [`names`];
5//! `linesmith presets apply <name>` writes [`body`] to the resolved
6//! config path.
7
8const MINIMAL: &str = include_str!("fixtures/minimal.toml");
9const DEVELOPER: &str = include_str!("fixtures/developer.toml");
10const POWER_USER: &str = include_str!("fixtures/power_user.toml");
11const COST_FOCUSED: &str = include_str!("fixtures/cost_focused.toml");
12const WORKTREE_HEAVY: &str = include_str!("fixtures/worktree_heavy.toml");
13
14/// A shipped preset. Display names are kebab-case for the CLI surface;
15/// file stems use underscore to match the Rust const idents (e.g.
16/// `POWER_USER` ↔ `power_user.toml` ↔ `"power-user"`).
17#[derive(Debug, Clone, Copy)]
18struct Preset {
19    name: &'static str,
20    body: &'static str,
21}
22
23/// Registry, in the order `linesmith presets list` emits.
24const PRESETS: &[Preset] = &[
25    Preset {
26        name: "minimal",
27        body: MINIMAL,
28    },
29    Preset {
30        name: "developer",
31        body: DEVELOPER,
32    },
33    Preset {
34        name: "power-user",
35        body: POWER_USER,
36    },
37    Preset {
38        name: "cost-focused",
39        body: COST_FOCUSED,
40    },
41    Preset {
42        name: "worktree-heavy",
43        body: WORKTREE_HEAVY,
44    },
45];
46
47/// Preset display names in registry order.
48pub fn names() -> impl Iterator<Item = &'static str> {
49    PRESETS.iter().map(|p| p.name)
50}
51
52/// Return the TOML body for a preset, or `None` if the name isn't
53/// registered. Case-sensitive.
54#[must_use]
55pub fn body(name: &str) -> Option<&'static str> {
56    PRESETS.iter().find(|p| p.name == name).map(|p| p.body)
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::build_lines;
63    use crate::config::Config;
64    use std::str::FromStr;
65
66    fn segments_of(preset: &str) -> Vec<String> {
67        let body = body(preset).expect("preset registered");
68        let cfg = Config::from_str(body).expect("parse");
69        cfg.line
70            .expect("preset has [line]")
71            .segments
72            .into_iter()
73            .filter_map(|e| e.segment_id().map(str::to_string))
74            .collect()
75    }
76
77    /// Per-line segment ids for a multi-line preset, sorted by parsed
78    /// integer key (matches what the builder feeds the renderer).
79    /// `numbered` carries raw `toml::Value`s (so the parser can
80    /// preserve the spec's "unknown keys are warnings" forward-compat
81    /// contract); the test helper walks the table shape directly,
82    /// panicking on anything other than a well-formed preset.
83    fn lines_of(preset: &str) -> Vec<Vec<String>> {
84        let body = body(preset).expect("preset registered");
85        let cfg = Config::from_str(body).expect("parse");
86        let line = cfg.line.expect("preset has [line]");
87        let mut sorted: Vec<(u32, Vec<String>)> = line
88            .numbered
89            .into_iter()
90            .map(|(k, value)| {
91                let n: u32 = k.parse().expect("preset uses positive-integer line keys");
92                let table = value.as_table().expect("preset [line.N] is a table");
93                let segs: Vec<String> = table["segments"]
94                    .as_array()
95                    .expect("preset [line.N].segments is an array")
96                    .iter()
97                    .map(|v| v.as_str().expect("preset segment is a string").to_string())
98                    .collect();
99                (n, segs)
100            })
101            .collect();
102        sorted.sort_by_key(|(n, _)| *n);
103        sorted.into_iter().map(|(_, segs)| segs).collect()
104    }
105
106    #[test]
107    fn registry_has_five_presets_in_stable_order() {
108        let got: Vec<&str> = names().collect();
109        assert_eq!(
110            got,
111            vec![
112                "minimal",
113                "developer",
114                "power-user",
115                "cost-focused",
116                "worktree-heavy",
117            ]
118        );
119    }
120
121    #[test]
122    fn every_preset_parses_without_warnings() {
123        // `build_lines` so the multi-line `power-user` preset
124        // doesn't trip `build_segments`'s "[line].segments is
125        // empty" warning.
126        for name in names() {
127            let body = body(name).expect("preset registered");
128            let cfg = Config::from_str(body)
129                .unwrap_or_else(|e| panic!("preset '{name}' failed to parse: {e}"));
130            let mut warnings: Vec<String> = Vec::new();
131            let _ = build_lines(Some(&cfg), None, |m: &str| warnings.push(m.to_string()));
132            assert!(
133                warnings.is_empty(),
134                "preset '{name}' emitted warnings: {warnings:?}"
135            );
136        }
137    }
138
139    #[test]
140    fn minimal_preset_segments_are_model_and_context_window() {
141        assert_eq!(segments_of("minimal"), vec!["model", "context_window"]);
142    }
143
144    #[test]
145    fn developer_preset_segments_match_spec() {
146        assert_eq!(
147            segments_of("developer"),
148            vec![
149                "model",
150                "workspace",
151                "context_window",
152                "cost",
153                "session_duration",
154                "rate_limit_5h",
155                "rate_limit_7d",
156            ]
157        );
158    }
159
160    #[test]
161    fn power_user_preset_is_multi_line_with_two_lines() {
162        // The spec's §Presets section marks power-user as v0.1's
163        // multi-line showcase. Pin both the layout mode and the
164        // per-line segment ordering so a refactor that flattens the
165        // preset back to single-line breaks loudly here.
166        let body = body("power-user").expect("preset registered");
167        let cfg = Config::from_str(body).expect("parse");
168        assert_eq!(
169            cfg.layout,
170            crate::config::LayoutMode::MultiLine,
171            "power-user must declare layout = \"multi-line\""
172        );
173        assert_eq!(
174            lines_of("power-user"),
175            vec![
176                vec!["model", "context_window", "workspace"],
177                vec![
178                    "rate_limit_5h",
179                    "rate_limit_7d",
180                    "cost",
181                    "effort",
182                    "tokens_total",
183                ],
184            ]
185        );
186    }
187
188    #[test]
189    fn cost_focused_preset_segments_match_spec() {
190        assert_eq!(
191            segments_of("cost-focused"),
192            vec![
193                "model",
194                "context_window",
195                "cost",
196                "rate_limit_5h",
197                "rate_limit_7d",
198            ]
199        );
200    }
201
202    #[test]
203    fn worktree_heavy_preset_segments_match_spec() {
204        assert_eq!(
205            segments_of("worktree-heavy"),
206            vec!["model", "workspace", "context_window"]
207        );
208    }
209
210    #[test]
211    fn preset_names_are_unique() {
212        // `body()` returns the first match, so a duplicate `name` would
213        // silently shadow any later entry.
214        let mut seen = std::collections::HashSet::new();
215        for name in names() {
216            assert!(seen.insert(name), "duplicate preset name: {name}");
217        }
218    }
219
220    #[test]
221    fn body_returns_none_for_unknown_name() {
222        assert!(body("definitely-not-a-preset").is_none());
223        assert!(body("").is_none());
224    }
225
226    #[test]
227    fn body_lookup_is_case_sensitive() {
228        assert!(body("minimal").is_some());
229        assert!(body("Minimal").is_none());
230        assert!(body("MINIMAL").is_none());
231        assert!(body("Developer").is_none());
232    }
233}