Skip to main content

handshake_core/config/
features.rs

1//! Receiver-owned feature flags, persisted through the versioned settings-file editor.
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use super::{Config, ConfigEditError, ConfigProblem, editing};
7use crate::{FileContent, FilePreview, FileSaveOutcome, FileSaveRequest, FileVersion};
8
9#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
10#[serde(default)]
11pub struct FeatureFlags {
12    pub cross_agent_memory_consolidation: bool,
13}
14
15#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
16#[serde(rename_all = "camelCase")]
17pub enum FeatureFlag {
18    CrossAgentMemoryConsolidation,
19}
20
21#[derive(Debug, Serialize)]
22pub struct FeatureFlagState {
23    pub id: FeatureFlag,
24    pub title: &'static str,
25    pub description: &'static str,
26    pub enabled: bool,
27}
28
29pub struct FeatureFlagSnapshot {
30    pub flags: Vec<FeatureFlagState>,
31    pub file: FilePreview,
32}
33
34impl FeatureFlag {
35    const ALL: [Self; 1] = [Self::CrossAgentMemoryConsolidation];
36
37    fn key(self) -> &'static str {
38        match self {
39            Self::CrossAgentMemoryConsolidation => "cross_agent_memory_consolidation",
40        }
41    }
42
43    fn state(self, flags: &FeatureFlags) -> FeatureFlagState {
44        match self {
45            Self::CrossAgentMemoryConsolidation => FeatureFlagState {
46                id: self,
47                title: "Cross-agent memory consolidation",
48                description: "Automatically share and consolidate project memories between supported agents.",
49                enabled: flags.cross_agent_memory_consolidation,
50            },
51        }
52    }
53}
54
55/// Reads the flag catalog and effective values, including imported configuration.
56/// # Errors
57/// Rejects invalid configuration rather than displaying fallback values as saved settings.
58pub fn read_feature_flags() -> Result<FeatureFlagSnapshot, ConfigEditError> {
59    snapshot(super::read_user_file()?)
60}
61
62fn snapshot(file: FilePreview) -> Result<FeatureFlagSnapshot, ConfigEditError> {
63    let FileContent::Text(source) = &file.content else {
64        return Err(ConfigEditError::FeatureFlags(
65            "Open a readable text configuration first.".into(),
66        ));
67    };
68    let loaded = Config::load_source(&file.path, source);
69    if let Some(problem) = loaded
70        .diagnostics
71        .into_iter()
72        .find(|d| d.problem != ConfigProblem::UnknownKey)
73    {
74        return Err(ConfigEditError::InvalidConfig(problem));
75    }
76    Ok(FeatureFlagSnapshot {
77        flags: FeatureFlag::ALL
78            .into_iter()
79            .map(|flag| flag.state(&loaded.config.feature_flags))
80            .collect(),
81        file,
82    })
83}
84
85/// Changes one root setting while preserving other settings, comments and conflict protection.
86/// # Errors
87/// Returns the settings editor's read, validation or write error. Concurrent edits return a conflict.
88pub fn set_feature_flag(
89    flag: FeatureFlag,
90    enabled: bool,
91    expected: FileVersion,
92) -> Result<FileSaveOutcome, ConfigEditError> {
93    Config::prepare_user()?;
94    let path = Config::user_path().ok_or(ConfigEditError::MissingDirectory)?;
95    set_at(&path, flag, enabled, expected)
96}
97
98fn set_at(
99    path: &Path,
100    flag: FeatureFlag,
101    enabled: bool,
102    expected: FileVersion,
103) -> Result<FileSaveOutcome, ConfigEditError> {
104    let file = editing::read_file(path)?;
105    if file.version.as_ref() != Some(&expected) {
106        return Ok(FileSaveOutcome::Conflict(file));
107    }
108    let snapshot = snapshot(file)?;
109    let FileContent::Text(source) = &snapshot.file.content else {
110        unreachable!("snapshot requires text")
111    };
112    let mut document = source
113        .parse::<toml_edit::DocumentMut>()
114        .map_err(|error| ConfigEditError::FeatureFlags(error.to_string()))?;
115    let flags = document
116        .entry("feature_flags")
117        .or_insert(toml_edit::table());
118    let mut value = toml_edit::value(enabled);
119    if let Some(existing) = flags.get(flag.key()).and_then(toml_edit::Item::as_value)
120        && let Some(new) = value.as_value_mut()
121    {
122        *new.decor_mut() = existing.decor().clone();
123    }
124    flags[flag.key()] = value;
125    let request = FileSaveRequest {
126        folder: snapshot
127            .file
128            .path
129            .parent()
130            .ok_or(ConfigEditError::InvalidPath)?
131            .to_owned(),
132        path: snapshot.file.path,
133        text: document.to_string(),
134        expected_version: expected,
135        overwrite: false,
136    };
137    editing::save_file(path, &request)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use std::fs;
144
145    #[test]
146    fn retired_tab_settings_are_ignored_and_memory_remains_opt_in() {
147        let root = tempfile::tempdir().unwrap();
148        let path = root.path().join("config.toml");
149        for retired in [false, true] {
150            for memory in [None, Some(false), Some(true)] {
151                let source = format!(
152                    "[feature_flags]\ntab_management = {retired}\n{}",
153                    memory.map_or_else(String::new, |enabled| format!(
154                        "cross_agent_memory_consolidation = {enabled}\n"
155                    ))
156                );
157                let loaded = Config::load_source(&path, &source);
158                assert_eq!(loaded.diagnostics, []);
159                assert_eq!(
160                    loaded.config.feature_flags.cross_agent_memory_consolidation,
161                    memory.unwrap_or(false)
162                );
163                let flags = FeatureFlag::ALL.map(|flag| flag.state(&loaded.config.feature_flags));
164                assert_eq!(flags.len(), 1);
165                assert_eq!(flags[0].id, FeatureFlag::CrossAgentMemoryConsolidation);
166                assert_eq!(flags[0].enabled, memory.unwrap_or(false));
167            }
168        }
169    }
170
171    #[test]
172    fn defaults_are_off_and_toggling_preserves_comments_and_other_settings() {
173        assert!(
174            !Config::default()
175                .feature_flags
176                .cross_agent_memory_consolidation
177        );
178        let root = tempfile::tempdir().unwrap();
179        let path = root.path().join("config.toml");
180        let original = "# Personal settings\n[terminal]\nfont_size = 17 # keep my size\n[feature_flags]\ncross_agent_memory_consolidation = false # my experiment\n";
181        fs::write(&path, original).unwrap();
182        let file = editing::read_file(&path).unwrap();
183        let result = set_at(
184            &path,
185            FeatureFlag::CrossAgentMemoryConsolidation,
186            true,
187            file.version.unwrap(),
188        )
189        .unwrap();
190        assert!(matches!(result, FileSaveOutcome::Saved(_)));
191        assert_eq!(
192            fs::read_to_string(&path).unwrap(),
193            original.replace("false #", "true #")
194        );
195        let saved = snapshot(editing::read_file(&path).unwrap()).unwrap();
196        assert!(saved.flags[0].enabled);
197        assert_eq!(Config::load(&path).config.terminal.font_size.points(), 17.0);
198    }
199
200    #[test]
201    fn root_toggle_overrides_imports_and_refuses_a_concurrent_edit() {
202        let root = tempfile::tempdir().unwrap();
203        let path = root.path().join("config.toml");
204        fs::write(
205            root.path().join("base.toml"),
206            "[feature_flags]\ncross_agent_memory_consolidation = true\n",
207        )
208        .unwrap();
209        fs::write(&path, "import = 'base.toml'\n# retained\n").unwrap();
210        let read = snapshot(editing::read_file(&path).unwrap()).unwrap();
211        assert!(read.flags[0].enabled);
212        set_at(
213            &path,
214            FeatureFlag::CrossAgentMemoryConsolidation,
215            false,
216            read.file.version.unwrap(),
217        )
218        .unwrap();
219        assert!(!snapshot(editing::read_file(&path).unwrap()).unwrap().flags[0].enabled);
220        let old = editing::read_file(&path).unwrap().version.unwrap();
221        fs::write(&path, "# external writer\n").unwrap();
222        assert!(matches!(
223            set_at(&path, FeatureFlag::CrossAgentMemoryConsolidation, true, old).unwrap(),
224            FileSaveOutcome::Conflict(_)
225        ));
226        assert_eq!(fs::read_to_string(&path).unwrap(), "# external writer\n");
227    }
228
229    #[test]
230    fn inline_and_dotted_flags_stay_valid_and_invalid_settings_are_held() {
231        let root = tempfile::tempdir().unwrap();
232        let path = root.path().join("config.toml");
233        for text in [
234            "feature_flags = { cross_agent_memory_consolidation = false } # keep\n",
235            "feature_flags.cross_agent_memory_consolidation = false\n",
236            "[terminal]\nfont_size = 16\n",
237        ] {
238            fs::write(&path, text).unwrap();
239            let file = editing::read_file(&path).unwrap();
240            set_at(
241                &path,
242                FeatureFlag::CrossAgentMemoryConsolidation,
243                true,
244                file.version.unwrap(),
245            )
246            .unwrap();
247            assert!(snapshot(editing::read_file(&path).unwrap()).unwrap().flags[0].enabled);
248        }
249        fs::write(&path, "[terminal]\nfont_size = 1000\n").unwrap();
250        let file = editing::read_file(&path).unwrap();
251        assert!(
252            set_at(
253                &path,
254                FeatureFlag::CrossAgentMemoryConsolidation,
255                true,
256                file.version.unwrap()
257            )
258            .is_err()
259        );
260        assert!(fs::read_to_string(&path).unwrap().contains("1000"));
261    }
262}