1use anyhow::{bail, Result};
2use toml_edit::{value, Array, DocumentMut, Item, Table};
3
4const DEFAULT_AUTO_PROMOTE_MIN_CONFIDENCE: f64 = 0.7;
5const STRICT_AUTO_PROMOTE_MIN_CONFIDENCE: f64 = 0.9;
6const DEFAULT_AUTO_PROMOTE_SOURCE_KIND: &str = "explicit_user_statement";
7const DEFAULT_AUTO_PROMOTE_REQUIRE_TEXT_SUPPORT: bool = true;
8const DEFAULT_AUTO_PROMOTE_STRICT: bool = false;
9const SUPPORTED_AUTO_PROMOTE_SOURCE_KINDS: &[&str] = &[
10 "explicit_user_statement",
11 "inferred_from_behavior",
12 "session_summary",
13 "third_party_statement",
14 "speculative_inference",
15];
16
17#[derive(Clone, Debug, PartialEq)]
18pub struct UserContextAutoPromoteConfig {
19 pub min_confidence: f64,
20 pub allowed_source_kinds: Vec<String>,
21 pub require_text_support: bool,
22 pub strict: bool,
23}
24
25#[derive(Clone, Debug, PartialEq)]
26pub struct AutoPromotePolicy {
27 pub min_confidence: f64,
28 pub allowed_source_kinds: Vec<String>,
29 pub require_text_support: bool,
30}
31
32impl AutoPromotePolicy {
33 pub fn relaxed_default() -> Self {
34 Self {
35 min_confidence: DEFAULT_AUTO_PROMOTE_MIN_CONFIDENCE,
36 allowed_source_kinds: default_source_kinds(),
37 require_text_support: DEFAULT_AUTO_PROMOTE_REQUIRE_TEXT_SUPPORT,
38 }
39 }
40
41 pub fn strict() -> Self {
42 Self {
43 min_confidence: STRICT_AUTO_PROMOTE_MIN_CONFIDENCE,
44 allowed_source_kinds: default_source_kinds(),
45 require_text_support: true,
46 }
47 }
48
49 pub fn allows_source_kind(&self, source_kind: &str) -> bool {
50 self.allowed_source_kinds
51 .iter()
52 .any(|allowed| allowed == source_kind)
53 }
54}
55
56impl UserContextAutoPromoteConfig {
57 pub fn effective_policy(&self) -> AutoPromotePolicy {
58 if self.strict {
59 return AutoPromotePolicy::strict();
60 }
61 AutoPromotePolicy {
62 min_confidence: self.min_confidence,
63 allowed_source_kinds: self.allowed_source_kinds.clone(),
64 require_text_support: self.require_text_support,
65 }
66 }
67}
68
69pub fn user_context_auto_promote_config() -> Result<UserContextAutoPromoteConfig> {
70 let mut doc = super::read_config_doc_or_default()?;
71 ensure_defaults(&mut doc)?;
72 user_context_auto_promote_config_from_doc(&doc)
73}
74
75pub(super) fn ensure_defaults(doc: &mut DocumentMut) -> Result<()> {
76 let user_context = super::top_table_mut(doc, "user_context")?;
77 let auto_promote = super::child_table_mut(user_context, "auto_promote")?;
78 set_f64_if_missing(
79 auto_promote,
80 "min_confidence",
81 DEFAULT_AUTO_PROMOTE_MIN_CONFIDENCE,
82 );
83 set_string_array_if_missing(
84 auto_promote,
85 "allowed_source_kinds",
86 &[DEFAULT_AUTO_PROMOTE_SOURCE_KIND],
87 );
88 super::set_bool_if_missing(
89 auto_promote,
90 "require_text_support",
91 DEFAULT_AUTO_PROMOTE_REQUIRE_TEXT_SUPPORT,
92 );
93 super::set_bool_if_missing(auto_promote, "strict", DEFAULT_AUTO_PROMOTE_STRICT);
94 Ok(())
95}
96
97fn user_context_auto_promote_config_from_doc(
98 doc: &DocumentMut,
99) -> Result<UserContextAutoPromoteConfig> {
100 let Some(table) = doc
101 .get("user_context")
102 .and_then(Item::as_table)
103 .and_then(|table| table.get("auto_promote"))
104 .and_then(Item::as_table)
105 else {
106 return Ok(default_config());
107 };
108
109 let strict = match table.get("strict") {
110 Some(item) => item
111 .as_bool()
112 .ok_or_else(|| anyhow::anyhow!("user_context.auto_promote.strict must be a boolean"))?,
113 None => DEFAULT_AUTO_PROMOTE_STRICT,
114 };
115 if strict {
116 return Ok(UserContextAutoPromoteConfig {
117 min_confidence: STRICT_AUTO_PROMOTE_MIN_CONFIDENCE,
118 allowed_source_kinds: default_source_kinds(),
119 require_text_support: true,
120 strict,
121 });
122 }
123
124 let min_confidence = match table.get("min_confidence") {
125 Some(item) => {
126 parse_auto_promote_confidence(item, "user_context.auto_promote.min_confidence")?
127 }
128 None => DEFAULT_AUTO_PROMOTE_MIN_CONFIDENCE,
129 };
130 let allowed_source_kinds = match table.get("allowed_source_kinds") {
131 Some(item) => parse_source_kinds(item)?,
132 None => default_source_kinds(),
133 };
134 let require_text_support = match table.get("require_text_support") {
135 Some(item) => item.as_bool().ok_or_else(|| {
136 anyhow::anyhow!("user_context.auto_promote.require_text_support must be a boolean")
137 })?,
138 None => DEFAULT_AUTO_PROMOTE_REQUIRE_TEXT_SUPPORT,
139 };
140 if !require_text_support {
141 bail!(
142 "user_context.auto_promote.require_text_support=false is not supported until queue support and non-retention source scanning are policy-aware"
143 );
144 }
145
146 Ok(UserContextAutoPromoteConfig {
147 min_confidence,
148 allowed_source_kinds,
149 require_text_support,
150 strict,
151 })
152}
153
154fn default_config() -> UserContextAutoPromoteConfig {
155 UserContextAutoPromoteConfig {
156 min_confidence: DEFAULT_AUTO_PROMOTE_MIN_CONFIDENCE,
157 allowed_source_kinds: default_source_kinds(),
158 require_text_support: DEFAULT_AUTO_PROMOTE_REQUIRE_TEXT_SUPPORT,
159 strict: DEFAULT_AUTO_PROMOTE_STRICT,
160 }
161}
162
163fn default_source_kinds() -> Vec<String> {
164 vec![DEFAULT_AUTO_PROMOTE_SOURCE_KIND.to_string()]
165}
166
167fn parse_auto_promote_confidence(item: &Item, field: &str) -> Result<f64> {
168 let value = item
169 .as_float()
170 .or_else(|| item.as_integer().map(|value| value as f64))
171 .ok_or_else(|| anyhow::anyhow!("{field} must be a number"))?;
172 if !(0.0..=1.0).contains(&value) {
173 bail!("{field} must be between 0.0 and 1.0, got {value}");
174 }
175 Ok(value)
176}
177
178fn parse_source_kinds(item: &Item) -> Result<Vec<String>> {
179 let array = item.as_array().ok_or_else(|| {
180 anyhow::anyhow!("user_context.auto_promote.allowed_source_kinds must be an array")
181 })?;
182 let mut values = Vec::new();
183 for (index, value) in array.iter().enumerate() {
184 let Some(raw) = value.as_str() else {
185 bail!(
186 "user_context.auto_promote.allowed_source_kinds[{}] must be a string",
187 index + 1
188 );
189 };
190 let trimmed = raw.trim().to_ascii_lowercase();
191 if trimmed.is_empty() {
192 bail!(
193 "user_context.auto_promote.allowed_source_kinds[{}] must not be empty",
194 index + 1
195 );
196 }
197 if !SUPPORTED_AUTO_PROMOTE_SOURCE_KINDS.contains(&trimmed.as_str()) {
198 bail!(
199 "user_context.auto_promote.allowed_source_kinds[{}] has unsupported source kind `{}`; expected one of: {}",
200 index + 1,
201 trimmed,
202 SUPPORTED_AUTO_PROMOTE_SOURCE_KINDS.join(", ")
203 );
204 }
205 if !values.iter().any(|existing| existing == &trimmed) {
206 values.push(trimmed);
207 }
208 }
209 if values.is_empty() {
210 bail!("user_context.auto_promote.allowed_source_kinds must not be empty");
211 }
212 Ok(values)
213}
214
215fn set_f64_if_missing(table: &mut Table, key: &str, value_f64: f64) {
216 if table.get(key).is_none() {
217 table[key] = value(value_f64);
218 }
219}
220
221fn set_string_array_if_missing(table: &mut Table, key: &str, values: &[&str]) {
222 if table.get(key).is_none() {
223 let mut array = Array::new();
224 for value in values {
225 array.push(*value);
226 }
227 table[key] = value(array);
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 fn with_user_context_config_path<T>(path: &std::path::Path, f: impl FnOnce() -> T) -> T {
236 let _guard = super::super::TEST_ENV_LOCK
237 .lock()
238 .expect("env lock should acquire");
239 let old = std::env::var("REMEM_CONFIG").ok();
240 unsafe { std::env::set_var("REMEM_CONFIG", path) };
241 let result = f();
242 match old {
243 Some(value) => unsafe { std::env::set_var("REMEM_CONFIG", value) },
244 None => unsafe { std::env::remove_var("REMEM_CONFIG") },
245 }
246 result
247 }
248
249 fn user_context_config_path(label: &str) -> std::path::PathBuf {
250 std::env::temp_dir().join(format!(
251 "remem-{label}-{}-{}.toml",
252 std::process::id(),
253 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
254 ))
255 }
256
257 #[test]
258 fn default_config_exposes_user_context_auto_promote_defaults() {
259 let text = super::super::default_config_text();
260 assert!(text.contains("[user_context.auto_promote]"), "{text}");
261 assert!(text.contains("min_confidence = 0.7"), "{text}");
262 assert!(
263 text.contains("allowed_source_kinds = [\"explicit_user_statement\"]"),
264 "{text}"
265 );
266 assert!(text.contains("require_text_support = true"), "{text}");
267 assert!(text.contains("strict = false"), "{text}");
268 }
269
270 #[test]
271 fn auto_promote_config_uses_defaults_when_section_is_missing() -> Result<()> {
272 let path = user_context_config_path("user-context-auto-promote-missing");
273 with_user_context_config_path(&path, || -> Result<()> {
274 std::fs::write(&path, "version = 1\n")?;
275 let config = user_context_auto_promote_config()?;
276 assert_eq!(config, default_config());
277 assert_eq!(
278 config.effective_policy(),
279 AutoPromotePolicy {
280 min_confidence: 0.7,
281 allowed_source_kinds: vec!["explicit_user_statement".to_string()],
282 require_text_support: true,
283 }
284 );
285 Ok(())
286 })?;
287 std::fs::remove_file(path)?;
288 Ok(())
289 }
290
291 #[test]
292 fn auto_promote_config_reads_valid_values() -> Result<()> {
293 let path = user_context_config_path("user-context-auto-promote-valid");
294 with_user_context_config_path(&path, || -> Result<()> {
295 std::fs::write(
296 &path,
297 "[user_context.auto_promote]\nmin_confidence = 0.75\nallowed_source_kinds = [\"explicit_user_statement\", \"inferred_from_behavior\", \"explicit_user_statement\"]\nrequire_text_support = true\nstrict = false\n",
298 )?;
299 let config = user_context_auto_promote_config()?;
300 assert_eq!(config.min_confidence, 0.75);
301 assert_eq!(
302 config.allowed_source_kinds,
303 vec![
304 "explicit_user_statement".to_string(),
305 "inferred_from_behavior".to_string()
306 ]
307 );
308 assert!(config.require_text_support);
309 assert!(!config.strict);
310 assert_eq!(
311 config.effective_policy(),
312 AutoPromotePolicy {
313 min_confidence: 0.75,
314 allowed_source_kinds: vec![
315 "explicit_user_statement".to_string(),
316 "inferred_from_behavior".to_string()
317 ],
318 require_text_support: true,
319 }
320 );
321 Ok(())
322 })?;
323 std::fs::remove_file(path)?;
324 Ok(())
325 }
326
327 #[test]
328 fn auto_promote_min_confidence_can_be_set_through_config_cli() -> Result<()> {
329 let path = user_context_config_path("user-context-auto-promote-cli-float");
330 with_user_context_config_path(&path, || -> Result<()> {
331 super::super::init_config()?;
332 super::super::set_config_value("user_context.auto_promote.min_confidence", "0.75")?;
333 let config = user_context_auto_promote_config()?;
334 let text = std::fs::read_to_string(&path)?;
335
336 assert_eq!(config.min_confidence, 0.75);
337 assert!(text.contains("min_confidence = 0.75"), "{text}");
338 assert!(!text.contains("min_confidence = \"0.75\""), "{text}");
339 Ok(())
340 })?;
341 std::fs::remove_file(path)?;
342 Ok(())
343 }
344
345 #[test]
346 fn strict_auto_promote_config_restores_old_policy() -> Result<()> {
347 let path = user_context_config_path("user-context-auto-promote-strict");
348 with_user_context_config_path(&path, || -> Result<()> {
349 std::fs::write(&path, "[user_context.auto_promote]\nstrict = true\n")?;
350 let config = user_context_auto_promote_config()?;
351 assert_eq!(
352 config,
353 UserContextAutoPromoteConfig {
354 min_confidence: 0.9,
355 allowed_source_kinds: vec!["explicit_user_statement".to_string()],
356 require_text_support: true,
357 strict: true,
358 }
359 );
360 assert_eq!(config.effective_policy(), AutoPromotePolicy::strict());
361 Ok(())
362 })?;
363 std::fs::remove_file(path)?;
364 Ok(())
365 }
366
367 #[test]
368 fn strict_auto_promote_config_ignores_other_policy_fields() -> Result<()> {
369 let path = user_context_config_path("user-context-auto-promote-strict-ignore");
370 with_user_context_config_path(&path, || -> Result<()> {
371 std::fs::write(
372 &path,
373 "[user_context.auto_promote]\nmin_confidence = 2.0\nallowed_source_kinds = [\"typo\"]\nrequire_text_support = \"false\"\nstrict = true\n",
374 )?;
375 let config = user_context_auto_promote_config()?;
376 assert_eq!(config.effective_policy(), AutoPromotePolicy::strict());
377 Ok(())
378 })?;
379 std::fs::remove_file(path)?;
380 Ok(())
381 }
382
383 #[test]
384 fn auto_promote_config_rejects_invalid_confidence() -> Result<()> {
385 for (label, value) in [
386 ("negative", "-0.1"),
387 ("too-high", "1.1"),
388 ("string", "\"0.7\""),
389 ] {
390 let path = user_context_config_path(&format!("user-context-auto-promote-{label}"));
391 with_user_context_config_path(&path, || -> Result<()> {
392 std::fs::write(
393 &path,
394 format!("[user_context.auto_promote]\nmin_confidence = {value}\n"),
395 )?;
396 let err = user_context_auto_promote_config()
397 .expect_err("invalid min_confidence must fail closed");
398 assert!(err.to_string().contains("min_confidence"), "{err}");
399 Ok(())
400 })?;
401 std::fs::remove_file(path)?;
402 }
403 Ok(())
404 }
405
406 #[test]
407 fn auto_promote_config_rejects_invalid_source_kinds() -> Result<()> {
408 for (label, value) in [
409 ("string", "\"explicit_user_statement\""),
410 ("empty-array", "[]"),
411 ("empty-string", "[\"\"]"),
412 ("non-string", "[1]"),
413 ("unknown", "[\"typo\"]"),
414 ] {
415 let path =
416 user_context_config_path(&format!("user-context-auto-promote-source-{label}"));
417 with_user_context_config_path(&path, || -> Result<()> {
418 std::fs::write(
419 &path,
420 format!("[user_context.auto_promote]\nallowed_source_kinds = {value}\n"),
421 )?;
422 let err = user_context_auto_promote_config()
423 .expect_err("invalid allowed_source_kinds must fail closed");
424 assert!(err.to_string().contains("allowed_source_kinds"), "{err}");
425 Ok(())
426 })?;
427 std::fs::remove_file(path)?;
428 }
429 Ok(())
430 }
431
432 #[test]
433 fn auto_promote_config_rejects_invalid_booleans() -> Result<()> {
434 for (label, key) in [("support", "require_text_support"), ("strict", "strict")] {
435 let path = user_context_config_path(&format!("user-context-auto-promote-bool-{label}"));
436 with_user_context_config_path(&path, || -> Result<()> {
437 std::fs::write(
438 &path,
439 format!("[user_context.auto_promote]\n{key} = \"true\"\n"),
440 )?;
441 let err = user_context_auto_promote_config()
442 .expect_err("invalid boolean must fail closed");
443 assert!(err.to_string().contains(key), "{err}");
444 Ok(())
445 })?;
446 std::fs::remove_file(path)?;
447 }
448 Ok(())
449 }
450
451 #[test]
452 fn auto_promote_config_rejects_disabled_text_support_until_policy_safe() -> Result<()> {
453 let path = user_context_config_path("user-context-auto-promote-text-support-disabled");
454 with_user_context_config_path(&path, || -> Result<()> {
455 std::fs::write(
456 &path,
457 "[user_context.auto_promote]\nrequire_text_support = false\n",
458 )?;
459 let err = user_context_auto_promote_config()
460 .expect_err("disabled text support must fail closed until queue support is safe");
461 assert!(
462 err.to_string().contains("require_text_support=false"),
463 "{err}"
464 );
465 Ok(())
466 })?;
467 std::fs::remove_file(path)?;
468 Ok(())
469 }
470}