1use std::sync::{Arc, Weak};
9
10use forge_primitives::settings::{
11 SettingsEditOp, SettingsWriteAsk, SettingsWriteChange, SettingsWriteEnding,
12};
13use forge_primitives::{EffectClass, SettingsEditRequest};
14use serde_json::Value;
15use uuid::Uuid;
16
17use crate::SessionSlot;
18use crate::workspace::Workspace;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
23pub(crate) enum SettingsError {
24 UnknownKey(String),
26 ForeignProject { key: String, org: String, project: String },
29 ForeignOrg { key: String, org: String, caller_org: String },
31 Wanted(String),
33 NoReader,
35 Abandoned,
37 Refused(String),
39}
40
41impl SettingsError {
42 pub(crate) fn message(&self) -> String {
44 match self {
45 Self::UnknownKey(key) => format!(
46 "`{key}` is not a key in this config; call settings__index to see the \
47 catalogue keys and the instances this config declares"
48 ),
49 Self::ForeignProject { key, org, project } => format!(
50 "`{key}` belongs to project {org}/{project}. Ask that project's lead through \
51 the peer channel - agents__send_message to org \"{org}\", project \
52 \"{project}\", label \"lead\" - or, if you have no peer channel, report the \
53 need to your reader instead"
54 ),
55 Self::ForeignOrg { key, org, caller_org } => format!(
56 "`{key}` belongs to org {org}; you are in {caller_org}. Ask one of that org's \
57 project leads through the peer channel - agents__send_message to org \"{org}\" \
58 and one of its projects, label \"lead\" - or, if you have no peer channel, \
59 report the need to your reader instead"
60 ),
61 Self::Wanted(message) => message.clone(),
62 Self::NoReader => "no client is attached to approve a configuration write, so \
63 nothing was written; the write needs the reader at a client"
64 .to_owned(),
65 Self::Abandoned => {
66 "the approval prompt closed without an answer; nothing was written".to_owned()
67 }
68 Self::Refused(message) => {
69 format!("the write was approved but did not land: {message}")
70 }
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq)]
77pub(crate) struct SettingsValue {
78 pub key: String,
79 pub pattern: String,
80 pub effect: EffectClass,
81 pub value: Value,
82}
83
84#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
86pub(crate) struct SettingsSetItem {
87 pub key: String,
88 pub value: Value,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93pub(crate) enum SettingsWriteOutcome {
94 Applied { effects: Vec<EffectClass> },
97 Declined,
99}
100
101#[async_trait::async_trait]
103pub(crate) trait SettingsFacade: Send + Sync {
104 async fn index(&self, caller: &SessionSlot) -> Result<String, SettingsError>;
106
107 async fn get(&self, caller: &SessionSlot, key: &str) -> Result<SettingsValue, SettingsError>;
110
111 async fn set(
114 &self,
115 caller: &SessionSlot,
116 items: Vec<SettingsSetItem>,
117 ) -> Result<SettingsWriteOutcome, SettingsError>;
118
119 async fn unset(
121 &self,
122 caller: &SessionSlot,
123 key: &str,
124 ) -> Result<SettingsWriteOutcome, SettingsError>;
125}
126
127struct ResolveOnDrop {
133 workspace: Arc<Workspace>,
134 id: Uuid,
135 caller: SessionSlot,
136}
137
138impl Drop for ResolveOnDrop {
139 fn drop(&mut self) {
140 self.workspace.settle_settings_write(self.id, &self.caller, SettingsWriteEnding::Abandoned);
141 }
142}
143
144pub(crate) struct ProdSettingsFacade {
145 workspace: Weak<Workspace>,
146}
147
148impl ProdSettingsFacade {
149 pub(crate) fn from_arc(workspace: &Arc<Workspace>) -> Arc<dyn SettingsFacade> {
150 Arc::new(Self { workspace: Arc::downgrade(workspace) })
151 }
152
153 fn workspace(&self) -> Result<Arc<Workspace>, SettingsError> {
154 self.workspace.upgrade().ok_or_else(|| {
155 SettingsError::Refused("the forge server is no longer running".to_owned())
156 })
157 }
158
159 async fn write(
165 &self,
166 caller: &SessionSlot,
167 items: Vec<SettingsSetItem>,
168 unset: bool,
169 ) -> Result<SettingsWriteOutcome, SettingsError> {
170 if items.is_empty() {
171 return Err(SettingsError::Wanted(
172 "a write needs at least one edit; nothing was written".to_owned(),
173 ));
174 }
175 let ws = self.workspace()?;
176 let config = ws.config_arc();
177 let document = ws.settings_snapshot();
178
179 let mut edits = Vec::with_capacity(items.len());
180 let mut changes = Vec::with_capacity(items.len());
181 let mut effects: Vec<EffectClass> = Vec::new();
182 for item in &items {
183 let target = crate::settings::resolve_target(&config, &document, &item.key)
184 .ok_or_else(|| SettingsError::UnknownKey(item.key.clone()))?;
185 let proposed = if unset { Value::Null } else { item.value.clone() };
186 if unset {
187 if target.edit.required {
188 return Err(SettingsError::Wanted(format!(
189 "`{}` is required and cannot be unset",
190 item.key
191 )));
192 }
193 if target.current.is_null() {
194 return Err(SettingsError::Wanted(format!(
195 "`{}` is already unset; nothing was written",
196 item.key
197 )));
198 }
199 } else {
200 if proposed.is_null() {
201 return Err(SettingsError::Wanted(format!(
202 "`{}` is cleared with settings__unset, not with a null value",
203 item.key
204 )));
205 }
206 crate::settings_write::validate(&proposed, &target.edit, &item.key)
207 .map_err(|error| SettingsError::Wanted(error.message))?;
208 }
209 edits.push(SettingsEditRequest {
210 area: target.area.clone(),
211 key_path: item.key.clone(),
212 pattern: target.pattern.clone(),
213 op: SettingsEditOp::Set,
214 value: (!unset).then(|| item.value.clone()),
215 });
216 changes.push(SettingsWriteChange {
217 key: item.key.clone(),
218 current: target.current.clone(),
219 proposed,
220 unset,
221 });
222 if !effects.contains(&target.class) {
223 effects.push(target.class);
224 }
225 }
226
227 if changes.iter().all(|change| change.current == change.proposed) {
229 let message = if changes.len() == 1 {
230 format!("`{}` already holds that value; nothing was written", changes[0].key)
231 } else {
232 "every key in this write already holds its value; nothing was written".to_owned()
233 };
234 return Err(SettingsError::Wanted(message));
235 }
236
237 let fingerprint = document.fingerprint.clone();
241 ws.validate_settings(&fingerprint, &edits).map_err(|error| match error {
242 crate::workspace::SettingsSaveError::Stale => SettingsError::Wanted(
243 "the configuration changed since it was read; call again to write against \
244 the current config"
245 .to_owned(),
246 ),
247 crate::workspace::SettingsSaveError::Invalid { message, .. }
248 | crate::workspace::SettingsSaveError::Refused { message, .. } => {
249 SettingsError::Wanted(message)
250 }
251 crate::workspace::SettingsSaveError::Io(message) => SettingsError::Wanted(format!(
252 "the configuration could not be read to validate this write, so nothing was \
253 written: {message}"
254 )),
255 })?;
256
257 let ask = SettingsWriteAsk { id: Uuid::new_v4(), edits: changes };
258 let (id, decision, answerable) = ws.register_settings_write(caller, ask);
259 if !answerable {
260 return Err(SettingsError::NoReader);
261 }
262 let _guard = ResolveOnDrop { workspace: Arc::clone(&ws), id, caller: caller.clone() };
267 let Ok(approved) = decision.await else {
268 ws.settle_settings_write(id, caller, SettingsWriteEnding::Abandoned);
269 return Err(SettingsError::Abandoned);
270 };
271 if !approved {
272 ws.settle_settings_write(id, caller, SettingsWriteEnding::Declined);
273 return Ok(SettingsWriteOutcome::Declined);
274 }
275 match ws.save_settings(&fingerprint, &edits) {
279 Ok(()) => {
280 ws.settle_settings_write(id, caller, SettingsWriteEnding::Applied);
281 Ok(SettingsWriteOutcome::Applied { effects })
282 }
283 Err(error) => {
284 let message = match error {
285 crate::workspace::SettingsSaveError::Stale => {
286 "the configuration changed since this write was shown; nothing was \
287 written. Call again to write against the current config"
288 .to_owned()
289 }
290 other => other.to_string(),
291 };
292 ws.settle_settings_write(
293 id,
294 caller,
295 SettingsWriteEnding::Refused { message: message.clone() },
296 );
297 Err(SettingsError::Refused(message))
298 }
299 }
300 }
301}
302
303#[async_trait::async_trait]
304impl SettingsFacade for ProdSettingsFacade {
305 async fn index(&self, caller: &SessionSlot) -> Result<String, SettingsError> {
306 let ws = self.workspace()?;
307 let document = ws.settings_snapshot();
308 Ok(crate::settings::index_text(&ws.config_arc(), &document, caller.org(), caller.project()))
309 }
310
311 async fn get(&self, caller: &SessionSlot, key: &str) -> Result<SettingsValue, SettingsError> {
312 let ws = self.workspace()?;
313 let config = ws.config_arc();
314 if let Some(owner) = crate::settings::owning_project(&config, key)
315 && (owner.org != caller.org() || owner.name != caller.project())
316 {
317 return Err(SettingsError::ForeignProject {
318 key: key.to_owned(),
319 org: owner.org.clone(),
320 project: owner.name.clone(),
321 });
322 }
323 if let Some(owner_org) = crate::settings::owning_org(&config, key)
324 && owner_org != caller.org()
325 {
326 return Err(SettingsError::ForeignOrg {
327 key: key.to_owned(),
328 org: owner_org.to_owned(),
329 caller_org: caller.org().to_owned(),
330 });
331 }
332 let document = ws.settings_snapshot();
333 let target = crate::settings::resolve_target(&config, &document, key)
334 .ok_or_else(|| SettingsError::UnknownKey(key.to_owned()))?;
335 Ok(SettingsValue {
336 key: target.path,
337 pattern: target.pattern,
338 effect: target.class,
339 value: target.current,
340 })
341 }
342
343 async fn set(
344 &self,
345 caller: &SessionSlot,
346 items: Vec<SettingsSetItem>,
347 ) -> Result<SettingsWriteOutcome, SettingsError> {
348 self.write(caller, items, false).await
349 }
350
351 async fn unset(
352 &self,
353 caller: &SessionSlot,
354 key: &str,
355 ) -> Result<SettingsWriteOutcome, SettingsError> {
356 self.write(caller, vec![SettingsSetItem { key: key.to_owned(), value: Value::Null }], true)
357 .await
358 }
359}
360
361#[cfg(test)]
363#[derive(Default)]
364pub(crate) struct MockSettingsFacade {
365 pub index_text: parking_lot::Mutex<String>,
366 pub values: parking_lot::Mutex<std::collections::HashMap<String, Value>>,
367 pub missing: parking_lot::Mutex<Option<SettingsError>>,
368 pub set_calls: parking_lot::Mutex<Vec<(SessionSlot, Vec<SettingsSetItem>)>>,
369 pub unset_calls: parking_lot::Mutex<Vec<(SessionSlot, String)>>,
370 pub write_result: parking_lot::Mutex<Option<Result<SettingsWriteOutcome, SettingsError>>>,
371}
372
373#[cfg(test)]
374impl MockSettingsFacade {
375 pub(crate) fn new() -> Self {
376 Self::default()
377 }
378
379 pub(crate) fn into_arc(self) -> Arc<dyn SettingsFacade> {
380 Arc::new(self)
381 }
382}
383
384#[cfg(test)]
385#[async_trait::async_trait]
386impl SettingsFacade for MockSettingsFacade {
387 async fn index(&self, _caller: &SessionSlot) -> Result<String, SettingsError> {
388 Ok(self.index_text.lock().clone())
389 }
390
391 async fn get(&self, _caller: &SessionSlot, key: &str) -> Result<SettingsValue, SettingsError> {
392 if let Some(error) = self.missing.lock().clone() {
393 return Err(error);
394 }
395 let value = self
396 .values
397 .lock()
398 .get(key)
399 .cloned()
400 .ok_or_else(|| SettingsError::UnknownKey(key.to_owned()))?;
401 Ok(SettingsValue {
402 key: key.to_owned(),
403 pattern: key.to_owned(),
404 effect: EffectClass::Live,
405 value,
406 })
407 }
408
409 async fn set(
410 &self,
411 caller: &SessionSlot,
412 items: Vec<SettingsSetItem>,
413 ) -> Result<SettingsWriteOutcome, SettingsError> {
414 self.set_calls.lock().push((caller.clone(), items));
415 self.write_result.lock().clone().unwrap_or(Ok(SettingsWriteOutcome::Declined))
416 }
417
418 async fn unset(
419 &self,
420 caller: &SessionSlot,
421 key: &str,
422 ) -> Result<SettingsWriteOutcome, SettingsError> {
423 self.unset_calls.lock().push((caller.clone(), key.to_owned()));
424 self.write_result.lock().clone().unwrap_or(Ok(SettingsWriteOutcome::Declined))
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use std::fs;
431 use std::sync::Arc;
432
433 use serde_json::json;
434
435 use super::*;
436 use crate::SessionSlot;
437
438 const CONFIG: &str = r#"
439[[orgs]]
440name = "Default"
441accounts = ["Stargate"]
442
443[[orgs.projects]]
444name = "forge"
445path = "/tmp/forge"
446max_workers = 2
447
448[[orgs.projects]]
449name = "web"
450path = "/tmp/web"
451
452[[orgs]]
453name = "Other"
454accounts = ["Stargate"]
455
456[[orgs.projects]]
457name = "elsewhere"
458path = "/tmp/elsewhere"
459
460[[accounts]]
461display_name = "Stargate"
462token = "t"
463models = ["claude-sonnet-5"]
464provider = "anthropic"
465"#;
466
467 fn fixture() -> (Arc<Workspace>, tempfile::TempDir) {
468 let dir = tempfile::tempdir().expect("tempdir");
469 let config_dir = crate::config::ensure_forge_data_dir(dir.path()).expect("forge/");
470 fs::write(config_dir.join("forge.toml"), CONFIG).expect("write forge.toml");
471 let workspace = Arc::new(Workspace::new_for_test(dir.path().to_path_buf()).expect("new"));
472 (workspace, dir)
473 }
474
475 fn caller() -> SessionSlot {
476 SessionSlot::lead("Default", "forge")
477 }
478
479 fn item(key: &str, value: serde_json::Value) -> SettingsSetItem {
480 SettingsSetItem { key: key.to_owned(), value }
481 }
482
483 fn facade(ws: &Arc<Workspace>) -> Arc<dyn SettingsFacade> {
484 ProdSettingsFacade::from_arc(ws)
485 }
486
487 async fn wait_for_write(ws: &Arc<Workspace>) -> Uuid {
488 for _ in 0..400 {
489 if let Some(id) = ws.settings_writes.lock().keys().next().copied() {
490 return id;
491 }
492 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
493 }
494 panic!("no configuration write was parked");
495 }
496
497 fn read_config(ws: &Workspace) -> String {
498 fs::read_to_string(ws.config_path()).expect("read forge.toml")
499 }
500
501 #[tokio::test]
504 async fn the_index_carries_the_catalogue_and_no_value() {
505 let (ws, _dir) = fixture();
506
507 let text = facade(&ws).index(&caller()).await.expect("the index reads");
508
509 assert!(text.contains("server.port"), "{text}");
510 assert!(text.contains("Default/forge"), "{text}");
511 assert!(!text.contains("Stargate\", \"t\""), "a value leaked: {text}");
512 }
513
514 #[tokio::test]
519 async fn get_reads_the_callers_own_project_and_the_global_keys() {
520 let (ws, _dir) = fixture();
521 let facade = facade(&ws);
522
523 let global = facade.get(&caller(), "server.port").await.expect("a global key reads");
524 assert_eq!(global.pattern, "server.port");
525
526 let own = facade
527 .get(&caller(), "orgs.Default.projects.forge.max_workers")
528 .await
529 .expect("the caller's own project key reads");
530 assert_eq!(own.value, json!(2));
531
532 let worker = SessionSlot::worker("Default", "forge", "settings-worker");
533 let same = facade
534 .get(&worker, "orgs.Default.projects.forge.max_workers")
535 .await
536 .expect("a worker reads its own project");
537 assert_eq!(same, own, "a lead and a worker share the own-project view");
538
539 let error = facade
540 .get(&caller(), "orgs.Default.projects.web.max_workers")
541 .await
542 .expect_err("another project's key is refused");
543 let message = error.message();
544 assert!(message.contains("orgs.Default.projects.web.max_workers"), "{message}");
545 assert!(message.contains("Default/web"), "the owner is named: {message}");
546 assert!(message.contains("agents__send_message"), "the request path is named: {message}");
547 assert!(message.contains("reader"), "the fallback is named: {message}");
548 }
549
550 #[tokio::test]
553 async fn an_approved_set_applies_and_a_declined_one_writes_nothing() {
554 let (ws, _dir) = fixture();
555 let _answerer = ws.subscribe_answerer();
556 let facade = facade(&ws);
557 let key = "orgs.Default.projects.forge.max_workers";
558
559 let task = tokio::spawn({
560 let facade = Arc::clone(&facade);
561 async move { facade.set(&caller(), vec![item(key, json!(4))]).await }
562 });
563 let id = wait_for_write(&ws).await;
564
565 assert_eq!(read_config(&ws), CONFIG, "nothing is written while the write waits");
566 let held = ws.pending_asks(&caller());
567 assert!(
568 held.iter().any(|ask| matches!(
569 ask,
570 crate::protocol::PendingAsk::SettingsWrite(held)
571 if held.id == id
572 && held.edits.len() == 1
573 && held.edits[0].current == json!(2)
574 && held.edits[0].proposed == json!(4)
575 )),
576 "the record read carries the key and both values",
577 );
578
579 assert!(ws.resolve_settings_write(id, &caller(), true));
580 let outcome = task.await.expect("no panic").expect("the write lands");
581 assert_eq!(
582 outcome,
583 SettingsWriteOutcome::Applied {
584 effects: vec![forge_primitives::EffectClass::NextSession],
585 },
586 );
587 assert!(read_config(&ws).contains("max_workers = 4"), "the value landed");
588 ws.reload_config().expect("the written file loads");
589 assert_eq!(ws.config_arc().projects[0].max_workers, Some(4));
590
591 let task = tokio::spawn({
592 let facade = Arc::clone(&facade);
593 async move { facade.set(&caller(), vec![item(key, json!(7))]).await }
594 });
595 let id = wait_for_write(&ws).await;
596 assert!(ws.resolve_settings_write(id, &caller(), false));
597 let outcome = task.await.expect("no panic").expect("a decline is an outcome");
598 assert_eq!(outcome, SettingsWriteOutcome::Declined);
599 assert!(read_config(&ws).contains("max_workers = 4"), "a decline writes nothing");
600 }
601
602 #[tokio::test]
605 async fn an_unset_global_section_key_reads_null() {
606 let (ws, _dir) = fixture();
607 let facade = facade(&ws);
608
609 let gotify = facade.get(&caller(), "gotify.url").await.expect("gotify.url reads");
610 assert_eq!(gotify.value, json!(null));
611 assert_eq!(gotify.pattern, "gotify.url");
612
613 let systemone =
614 facade.get(&caller(), "systemone.enabled").await.expect("systemone.enabled reads");
615 assert_eq!(systemone.value, json!(null));
616 }
617
618 #[tokio::test]
622 async fn an_unset_optional_key_reads_null_and_can_be_set() {
623 let (ws, _dir) = fixture();
624 let _answerer = ws.subscribe_answerer();
625 let facade = facade(&ws);
626 let key = "orgs.Default.projects.forge.handoff.enabled";
627
628 let read = facade.get(&caller(), key).await.expect("an unset optional key reads");
629 assert_eq!(read.value, json!(null), "the key is addressable before it is set");
630 assert_eq!(read.pattern, "orgs[].projects[].handoff.enabled");
631
632 let (changed, mut edits) = tokio::sync::mpsc::unbounded_channel();
635 let _watch = crate::env::config_watch::watch(ws.config_path(), changed).expect("the watch");
636 let watching = tokio::spawn({
637 let ws = Arc::clone(&ws);
638 async move {
639 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
640 loop {
641 tokio::select! {
642 Some(()) = edits.recv() => {
643 let _ = ws.reload_config();
644 }
645 () = tokio::time::sleep_until(deadline) => return false,
646 }
647 if ws.config_arc().projects[0].handoff.as_ref().and_then(|h| h.enabled)
648 == Some(true)
649 {
650 return true;
651 }
652 }
653 }
654 });
655
656 let task = tokio::spawn({
657 let facade = Arc::clone(&facade);
658 async move { facade.set(&caller(), vec![item(key, json!(true))]).await }
659 });
660 let id = wait_for_write(&ws).await;
661 assert!(ws.resolve_settings_write(id, &caller(), true));
662 let outcome = task.await.expect("no panic").expect("the write lands");
663 assert!(matches!(outcome, SettingsWriteOutcome::Applied { .. }));
664 assert!(read_config(&ws).contains("enabled = true"), "the key was created");
665 assert!(watching.await.expect("no panic"), "the watcher applied the written config");
666 }
667
668 #[tokio::test]
672 async fn a_write_to_another_projects_key_is_held_too() {
673 let (ws, _dir) = fixture();
674 let _answerer = ws.subscribe_answerer();
675 let facade = facade(&ws);
676 let key = "orgs.Default.projects.web.max_workers";
677
678 let task = tokio::spawn({
679 let facade = Arc::clone(&facade);
680 async move { facade.set(&caller(), vec![item(key, json!(5))]).await }
681 });
682 let id = wait_for_write(&ws).await;
683 let held = ws.pending_asks(&caller());
684 assert!(
685 held.iter().any(|ask| matches!(
686 ask,
687 crate::protocol::PendingAsk::SettingsWrite(held)
688 if held.id == id
689 && held.edits.iter().any(|edit| edit.key == key && edit.proposed == json!(5))
690 )),
691 "the write parks with the foreign key the reader has to see",
692 );
693
694 assert!(ws.resolve_settings_write(id, &caller(), true));
695 let outcome = task.await.expect("no panic").expect("the write lands");
696 assert!(matches!(outcome, SettingsWriteOutcome::Applied { .. }));
697 assert!(read_config(&ws).contains("max_workers = 5"));
698 }
699
700 #[tokio::test]
704 async fn another_orgs_org_level_key_is_refused_by_name() {
705 let (ws, _dir) = fixture();
706
707 let error = facade(&ws)
708 .get(&caller(), "orgs.Other.browser_profile")
709 .await
710 .expect_err("another org's key is refused");
711
712 let message = error.message();
713 assert!(message.contains("orgs.Other.browser_profile"), "{message}");
714 assert!(message.contains("belongs to org Other"), "{message}");
715 assert!(message.contains("agents__send_message"), "{message}");
716 assert!(message.contains("reader"), "{message}");
717 }
718
719 #[tokio::test]
724 async fn a_cancelled_call_settles_the_write_as_abandoned() {
725 let (ws, _dir) = fixture();
726 let _answerer = ws.subscribe_answerer();
727 let mut updates = ws.subscribe_mirror();
728 let facade = facade(&ws);
729 let task = tokio::spawn({
730 let facade = Arc::clone(&facade);
731 async move {
732 facade
733 .set(&caller(), vec![item("orgs.Default.projects.forge.max_workers", json!(5))])
734 .await
735 }
736 });
737 let _id = wait_for_write(&ws).await;
738
739 task.abort();
740 let _ = task.await;
741 for _ in 0..200 {
742 if ws.settings_writes.lock().is_empty() {
743 break;
744 }
745 tokio::task::yield_now().await;
746 }
747
748 assert!(ws.settings_writes.lock().is_empty(), "the cancelled call left nothing parked");
749 let mut endings = Vec::new();
750 while let Ok(frame) = updates.try_recv() {
751 if let crate::protocol::SessionUpdate::SettingsWriteResolved { ending, .. } =
752 std::sync::Arc::unwrap_or_clone(frame.update)
753 {
754 endings.push(ending);
755 }
756 }
757 assert_eq!(endings, vec![SettingsWriteEnding::Abandoned]);
758 }
759
760 #[tokio::test]
763 async fn an_unknown_key_or_a_wrong_type_is_refused_before_the_ask() {
764 let (ws, _dir) = fixture();
765 let _answerer = ws.subscribe_answerer();
766 let facade = facade(&ws);
767
768 let unknown = facade
769 .set(&caller(), vec![item("orgs.Default.projects.forge.nope", json!(1))])
770 .await
771 .expect_err("an unknown key is refused");
772 assert!(unknown.message().contains("orgs.Default.projects.forge.nope"));
773 assert!(ws.settings_writes.lock().is_empty(), "no ask was minted");
774
775 let wrong = facade
776 .set(&caller(), vec![item("orgs.Default.projects.forge.max_workers", json!("four"))])
777 .await
778 .expect_err("a wrong type is refused");
779 assert!(wrong.message().contains("max_workers"), "{}", wrong.message());
780 assert!(wrong.message().contains("whole number"), "{}", wrong.message());
781 assert!(ws.settings_writes.lock().is_empty(), "no ask was minted");
782 }
783
784 #[tokio::test]
787 async fn unset_refuses_a_required_key_and_clears_an_optional_one() {
788 let (ws, _dir) = fixture();
789 let _answerer = ws.subscribe_answerer();
790 let facade = facade(&ws);
791
792 let required = facade
793 .unset(&caller(), "orgs.Default.projects.forge.path")
794 .await
795 .expect_err("a required key cannot be unset");
796 assert!(required.message().contains("required"), "{}", required.message());
797
798 let task = tokio::spawn({
799 let facade = Arc::clone(&facade);
800 async move { facade.unset(&caller(), "orgs.Default.projects.forge.max_workers").await }
801 });
802 let id = wait_for_write(&ws).await;
803 assert!(ws.resolve_settings_write(id, &caller(), true));
804 let outcome = task.await.expect("no panic").expect("the clear lands");
805 assert!(matches!(outcome, SettingsWriteOutcome::Applied { .. }));
806 assert!(
807 !read_config(&ws).contains("max_workers"),
808 "the key left the file: {}",
809 read_config(&ws),
810 );
811 }
812
813 #[tokio::test]
815 async fn a_write_with_no_reader_fails_closed() {
816 let (ws, _dir) = fixture();
817
818 let error = facade(&ws)
819 .set(&caller(), vec![item("orgs.Default.projects.forge.max_workers", json!(4))])
820 .await
821 .expect_err("no reader means no write");
822
823 assert_eq!(error, SettingsError::NoReader);
824 assert!(ws.settings_writes.lock().is_empty());
825 assert_eq!(read_config(&ws), CONFIG, "nothing was written");
826 }
827
828 #[tokio::test]
832 async fn a_write_shown_against_a_moved_file_is_refused() {
833 let (ws, _dir) = fixture();
834 let _answerer = ws.subscribe_answerer();
835 let facade = facade(&ws);
836
837 let task = tokio::spawn({
838 let facade = Arc::clone(&facade);
839 async move {
840 facade
841 .set(&caller(), vec![item("orgs.Default.projects.forge.max_workers", json!(4))])
842 .await
843 }
844 });
845 let id = wait_for_write(&ws).await;
846 fs::write(ws.config_path(), format!("{CONFIG}\n# moved\n")).expect("move the file");
847 assert!(ws.resolve_settings_write(id, &caller(), true));
848
849 let error = task.await.expect("no panic").expect_err("a stale write is refused");
850 assert!(matches!(error, SettingsError::Refused(_)), "{error:?}");
851 assert!(!read_config(&ws).contains("max_workers = 4"), "nothing was written");
852 }
853
854 #[tokio::test]
859 async fn a_vault_pair_creates_the_section_in_one_write() {
860 let (ws, _dir) = fixture();
861 let _answerer = ws.subscribe_answerer();
862 let facade = facade(&ws);
863
864 let task = tokio::spawn({
865 let facade = Arc::clone(&facade);
866 async move {
867 facade
868 .set(
869 &caller(),
870 vec![
871 item("vault.url", json!("https://vault.test")),
872 item("vault.email", json!("me@vault.test")),
873 ],
874 )
875 .await
876 }
877 });
878 let id = wait_for_write(&ws).await;
879
880 assert_eq!(read_config(&ws), CONFIG, "nothing is written while the pair waits");
881 let held = ws.pending_asks(&caller());
882 let ask = held
883 .iter()
884 .find_map(|ask| match ask {
885 crate::protocol::PendingAsk::SettingsWrite(held) if held.id == id => {
886 Some(held.clone())
887 }
888 _ => None,
889 })
890 .expect("the pair parked one write");
891 assert_eq!(ask.edits.len(), 2, "one card carries both keys");
892 assert_eq!(ask.edits[0].key, "vault.url");
893 assert_eq!(ask.edits[0].current, json!(null));
894 assert_eq!(ask.edits[0].proposed, json!("https://vault.test"));
895 assert_eq!(ask.edits[1].key, "vault.email");
896 assert_eq!(ask.edits[1].proposed, json!("me@vault.test"));
897
898 assert!(ws.resolve_settings_write(id, &caller(), true));
899 let outcome = task.await.expect("no panic").expect("the pair lands");
900 assert!(
901 matches!(
902 outcome,
903 SettingsWriteOutcome::Applied { ref effects }
904 if effects.as_slice() == [forge_primitives::EffectClass::Live]
905 ),
906 "{outcome:?}",
907 );
908
909 let written = read_config(&ws);
910 assert!(written.contains("[vault]"), "the section was created: {written}");
911 assert!(written.contains("url = \"https://vault.test\""), "{written}");
912 assert!(written.contains("email = \"me@vault.test\""), "{written}");
913 ws.reload_config().expect("the written file loads");
914 let vault = ws.config_arc().vault.clone().expect("the section is live");
915 assert_eq!(vault.url, "https://vault.test");
916 assert_eq!(vault.email, "me@vault.test");
917 }
918
919 #[tokio::test]
923 async fn a_partial_pair_is_refused_before_the_ask() {
924 let (ws, _dir) = fixture();
925 let _answerer = ws.subscribe_answerer();
926 let facade = facade(&ws);
927
928 let error = tokio::time::timeout(
929 std::time::Duration::from_secs(10),
930 facade.set(&caller(), vec![item("vault.url", json!("https://vault.test"))]),
931 )
932 .await
933 .expect("the refusal precedes the ask, so nothing waits on an approval")
934 .expect_err("a half [vault] is refused");
935
936 let message = error.message();
937 assert!(message.contains("vault"), "the section is named: {message}");
938 assert!(message.contains("email"), "the missing key is named: {message}");
939 assert!(ws.settings_writes.lock().is_empty(), "no ask was minted");
940 assert_eq!(read_config(&ws), CONFIG, "nothing was written");
941 }
942
943 #[tokio::test]
946 async fn an_unreadable_config_refuses_the_batch_before_the_ask() {
947 let (ws, _dir) = fixture();
948 let _answerer = ws.subscribe_answerer();
949 let facade = facade(&ws);
950 std::fs::remove_file(ws.config_path()).expect("remove forge.toml");
951
952 let error = tokio::time::timeout(
953 std::time::Duration::from_secs(10),
954 facade.set(
955 &caller(),
956 vec![
957 item("vault.url", json!("https://vault.test")),
958 item("vault.email", json!("me@vault.test")),
959 ],
960 ),
961 )
962 .await
963 .expect("the refusal precedes the ask, so nothing waits on an approval")
964 .expect_err("an unreadable config refuses the write");
965
966 let message = error.message();
967 assert!(message.contains("could not be read"), "{message}");
968 assert!(!message.contains("approved"), "no reader was asked: {message}");
969 assert!(ws.settings_writes.lock().is_empty(), "no ask was minted");
970 }
971
972 #[tokio::test]
975 async fn one_bad_key_refuses_the_whole_batch() {
976 let (ws, _dir) = fixture();
977 let _answerer = ws.subscribe_answerer();
978 let facade = facade(&ws);
979
980 let error = tokio::time::timeout(
981 std::time::Duration::from_secs(10),
982 facade.set(
983 &caller(),
984 vec![
985 item("vault.url", json!("https://vault.test")),
986 item("vault.email", json!("me@vault.test")),
987 item("orgs.Default.projects.forge.max_workers", json!("four")),
988 ],
989 ),
990 )
991 .await
992 .expect("the refusal precedes the ask, so nothing waits on an approval")
993 .expect_err("a wrong-typed member refuses the batch");
994
995 assert!(error.message().contains("max_workers"), "{}", error.message());
996 assert!(ws.settings_writes.lock().is_empty(), "no ask was minted");
997 assert_eq!(read_config(&ws), CONFIG, "the good pair was not written");
998 }
999
1000 #[tokio::test]
1004 async fn a_batch_naming_one_key_twice_is_refused_before_the_ask() {
1005 let (ws, _dir) = fixture();
1006 let _answerer = ws.subscribe_answerer();
1007 let facade = facade(&ws);
1008 let key = "orgs.Default.projects.forge.max_workers";
1009
1010 let error = tokio::time::timeout(
1011 std::time::Duration::from_secs(10),
1012 facade.set(&caller(), vec![item(key, json!(4)), item(key, json!(2))]),
1013 )
1014 .await
1015 .expect("the refusal precedes the ask, so nothing waits on an approval")
1016 .expect_err("a duplicate key is refused");
1017
1018 assert!(error.message().contains("max_workers"), "{}", error.message());
1019 assert!(error.message().contains("twice"), "{}", error.message());
1020 assert!(ws.settings_writes.lock().is_empty(), "no ask was minted");
1021 assert_eq!(read_config(&ws), CONFIG, "nothing was written");
1022 }
1023
1024 #[tokio::test]
1026 async fn a_declined_batch_writes_nothing() {
1027 let (ws, _dir) = fixture();
1028 let _answerer = ws.subscribe_answerer();
1029 let facade = facade(&ws);
1030
1031 let task = tokio::spawn({
1032 let facade = Arc::clone(&facade);
1033 async move {
1034 facade
1035 .set(
1036 &caller(),
1037 vec![
1038 item("vault.url", json!("https://vault.test")),
1039 item("vault.email", json!("me@vault.test")),
1040 ],
1041 )
1042 .await
1043 }
1044 });
1045 let id = wait_for_write(&ws).await;
1046 assert!(ws.resolve_settings_write(id, &caller(), false));
1047
1048 let outcome = task.await.expect("no panic").expect("a decline is an outcome");
1049 assert_eq!(outcome, SettingsWriteOutcome::Declined);
1050 assert_eq!(read_config(&ws), CONFIG, "a decline writes nothing");
1051 assert!(ws.config_arc().vault.is_none(), "the section stayed absent");
1052 }
1053}