1use 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
55pub 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
85pub 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}