1use std::collections::{BTreeMap, BTreeSet};
2use std::env;
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5
6use crate::workspace::Workspace;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use thiserror::Error;
10
11pub const SETTINGS_SCHEMA_VERSION: u32 = 1;
12
13type ParsedTokens = (BTreeMap<String, String>, BTreeSet<String>, Vec<String>);
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Command {
17 Run,
18 Resume { session_id: Option<String> },
19 ReplaceSession { predecessor_session_id: String },
20 Inspect { session_id: Option<String> },
21 Doctor,
22 Login,
23 Logout,
24 Status,
25 Task(TaskCommand),
26 Help,
27 Version,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum TaskCommand {
32 Select(String),
33 Show,
34 Clear,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Invocation {
39 pub command: Command,
40 pub options: BTreeMap<String, String>,
41 pub switches: BTreeSet<String>,
42}
43
44impl Invocation {
45 #[must_use]
46 pub fn json(&self) -> bool {
47 self.switches.contains("json")
48 }
49
50 #[must_use]
51 pub fn switched(&self, name: &str) -> bool {
52 self.switches.contains(name)
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct WorkspacePaths {
58 pub workspace: PathBuf,
59 pub state_root: PathBuf,
60 pub workspace_state: PathBuf,
61 pub database: PathBuf,
62 pub settings: PathBuf,
63}
64
65impl WorkspacePaths {
66 pub fn prepare(&self) -> Result<(), UxError> {
67 fs::create_dir_all(&self.workspace_state)?;
68 if is_implementation_workspace(&self.workspace) {
69 let canonical_state = canonicalize_for_containment(&self.workspace_state)?;
70 if is_contained_by(&canonical_state, &self.workspace) {
71 return Err(UxError::StateInsideWorkspace(canonical_state));
72 }
73 }
74 if let Some(parent) = self.database.parent() {
75 fs::create_dir_all(parent)?;
76 if is_implementation_workspace(&self.workspace) {
77 let canonical_parent = canonicalize_for_containment(parent)?;
78 if is_contained_by(&canonical_parent, &self.workspace) {
79 return Err(UxError::StateInsideWorkspace(canonical_parent));
80 }
81 }
82 }
83 Ok(())
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct WorkspaceSettings {
89 pub schema_version: u32,
90 pub canonical_workspace: PathBuf,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub selected_falsegreen_task: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub mcp_config: Option<PathBuf>,
95}
96
97impl WorkspaceSettings {
98 #[must_use]
99 pub fn empty(workspace: impl Into<PathBuf>) -> Self {
100 Self {
101 schema_version: SETTINGS_SCHEMA_VERSION,
102 canonical_workspace: workspace.into(),
103 selected_falsegreen_task: None,
104 mcp_config: None,
105 }
106 }
107
108 pub fn load(paths: &WorkspacePaths) -> Result<Self, UxError> {
109 let bytes = match fs::read(&paths.settings) {
110 Ok(bytes) => bytes,
111 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
112 return Ok(Self::empty(&paths.workspace));
113 }
114 Err(error) => return Err(error.into()),
115 };
116 let settings: Self = serde_json::from_slice(&bytes)?;
117 if settings.schema_version != SETTINGS_SCHEMA_VERSION {
118 return Err(UxError::UnsupportedSettingsSchema(settings.schema_version));
119 }
120 if settings.canonical_workspace != paths.workspace {
121 return Err(UxError::SettingsWorkspaceMismatch {
122 expected: paths.workspace.clone(),
123 actual: settings.canonical_workspace,
124 });
125 }
126 Ok(settings)
127 }
128
129 pub fn save(&self, paths: &WorkspacePaths) -> Result<(), UxError> {
130 paths.prepare()?;
131 let bytes = serde_json::to_vec_pretty(self)?;
132 let temporary = paths
133 .settings
134 .with_extension(format!("json.tmp-{}", std::process::id()));
135 fs::write(&temporary, bytes)?;
136 fs::rename(temporary, &paths.settings)?;
137 Ok(())
138 }
139}
140
141#[derive(Debug, Error)]
142pub enum UxError {
143 #[error("{0}")]
144 Usage(String),
145 #[error("workspace path could not be resolved: {0}")]
146 WorkspaceIo(std::io::Error),
147 #[error("state path is inside the implementation workspace: {0}")]
148 StateInsideWorkspace(PathBuf),
149 #[error("no state home is available; set FALSEGREEN_AGENT_HOME or HOME")]
150 MissingStateHome,
151 #[error("workspace settings use unsupported schema version {0}")]
152 UnsupportedSettingsSchema(u32),
153 #[error("workspace settings belong to {actual}, not {expected}")]
154 SettingsWorkspaceMismatch { expected: PathBuf, actual: PathBuf },
155 #[error("workspace settings are malformed: {0}")]
156 SettingsJson(#[from] serde_json::Error),
157 #[error("state I/O failed: {0}")]
158 Io(#[from] std::io::Error),
159}
160
161pub fn parse(arguments: impl IntoIterator<Item = String>) -> Result<Invocation, UxError> {
162 let mut arguments: Vec<String> = arguments.into_iter().collect();
163 if arguments
164 .iter()
165 .any(|argument| argument == "--help" || argument == "-h")
166 {
167 return Ok(Invocation {
168 command: Command::Help,
169 options: BTreeMap::new(),
170 switches: BTreeSet::new(),
171 });
172 }
173 if arguments.as_slice() == ["--version"] || arguments.as_slice() == ["-V"] {
174 return Ok(Invocation {
175 command: Command::Version,
176 options: BTreeMap::new(),
177 switches: BTreeSet::new(),
178 });
179 }
180
181 let command_name = arguments
182 .first()
183 .is_some_and(|argument| is_command(argument))
184 .then(|| arguments.remove(0));
185 if command_name.as_deref() == Some("help") {
186 return Ok(Invocation {
187 command: Command::Help,
188 options: BTreeMap::new(),
189 switches: BTreeSet::new(),
190 });
191 }
192 if command_name.as_deref() == Some("task") {
193 return parse_task(arguments);
194 }
195
196 let (mut options, switches, positionals) = parse_tokens(arguments)?;
197 let command = match command_name.as_deref() {
198 None | Some("run") => {
199 if !positionals.is_empty() {
200 if options.contains_key("goal") {
201 return Err(UxError::Usage(
202 "provide the goal either positionally or with --goal, not both".to_owned(),
203 ));
204 }
205 options.insert("goal".to_owned(), positionals.join(" "));
206 }
207 Command::Run
208 }
209 Some("resume") => {
210 if positionals.len() > 1 {
211 return Err(UxError::Usage(
212 "resume accepts at most one session ID".to_owned(),
213 ));
214 }
215 let positional = positionals.into_iter().next();
216 if positional.is_some() && options.contains_key("session") {
217 return Err(UxError::Usage(
218 "provide the session either positionally or with --session, not both"
219 .to_owned(),
220 ));
221 }
222 Command::Resume {
223 session_id: positional.or_else(|| options.get("session").cloned()),
224 }
225 }
226 Some("replace-session") => {
227 if positionals.len() != 1 || positionals[0].trim().is_empty() {
228 return Err(UxError::Usage(
229 "replace-session requires exactly one predecessor session ID".to_owned(),
230 ));
231 }
232 if !options.contains_key("candidate-sha256") {
233 return Err(UxError::Usage(
234 "replace-session requires --candidate-sha256 SHA256".to_owned(),
235 ));
236 }
237 if options.keys().any(|name| {
238 !matches!(
239 name.as_str(),
240 "workspace" | "state-dir" | "db" | "candidate-sha256"
241 )
242 }) || switches.iter().any(|name| name != "json")
243 {
244 return Err(UxError::Usage(
245 "replace-session accepts only workspace/state paths, --candidate-sha256, and --json"
246 .to_owned(),
247 ));
248 }
249 Command::ReplaceSession {
250 predecessor_session_id: positionals[0].clone(),
251 }
252 }
253 Some("inspect") => {
254 if positionals.len() > 1 {
255 return Err(UxError::Usage(
256 "inspect accepts at most one session ID".to_owned(),
257 ));
258 }
259 let positional = positionals.into_iter().next();
260 if positional.is_some() && options.contains_key("session") {
261 return Err(UxError::Usage(
262 "provide the session either positionally or with --session, not both"
263 .to_owned(),
264 ));
265 }
266 Command::Inspect {
267 session_id: positional.or_else(|| options.get("session").cloned()),
268 }
269 }
270 Some("doctor") => {
271 if !positionals.is_empty() {
272 return Err(UxError::Usage(
273 "doctor does not accept positional arguments".to_owned(),
274 ));
275 }
276 Command::Doctor
277 }
278 Some("login") => {
279 require_no_positionals(&positionals, "login")?;
280 require_auth_options(&options, &switches, true)?;
281 Command::Login
282 }
283 Some("logout") => {
284 require_no_positionals(&positionals, "logout")?;
285 require_auth_options(&options, &switches, false)?;
286 Command::Logout
287 }
288 Some("status") => {
289 require_no_positionals(&positionals, "status")?;
290 require_auth_options(&options, &switches, false)?;
291 Command::Status
292 }
293 Some("version") => Command::Version,
294 Some(other) => return Err(UxError::Usage(format!("unknown command {other:?}"))),
295 };
296 if options.contains_key("token") && command != Command::Login {
297 return Err(UxError::Usage(
298 "--token is accepted only by `falsegreen-agent login`".to_owned(),
299 ));
300 }
301 if switches
302 .iter()
303 .any(|name| matches!(name.as_str(), "mcp-smoke" | "verify-cache"))
304 && command != Command::Doctor
305 {
306 return Err(UxError::Usage(
307 "--mcp-smoke and --verify-cache are doctor-only switches".to_owned(),
308 ));
309 }
310 Ok(Invocation {
311 command,
312 options,
313 switches,
314 })
315}
316
317fn parse_task(arguments: Vec<String>) -> Result<Invocation, UxError> {
318 let action = arguments
319 .first()
320 .ok_or_else(|| UxError::Usage("task requires select, show, or clear".to_owned()))?
321 .clone();
322 let (options, switches, positionals) = parse_tokens(arguments.into_iter().skip(1))?;
323 let command = match action.as_str() {
324 "select" => {
325 if positionals.len() != 1 || positionals[0].trim().is_empty() {
326 return Err(UxError::Usage(
327 "task select requires exactly one FalseGreen task ID".to_owned(),
328 ));
329 }
330 if positionals[0].chars().any(char::is_whitespace) {
331 return Err(UxError::Usage(
332 "a FalseGreen task ID cannot contain whitespace".to_owned(),
333 ));
334 }
335 TaskCommand::Select(positionals[0].clone())
336 }
337 "show" => {
338 require_no_positionals(&positionals, "task show")?;
339 TaskCommand::Show
340 }
341 "clear" => {
342 require_no_positionals(&positionals, "task clear")?;
343 TaskCommand::Clear
344 }
345 _ => {
346 return Err(UxError::Usage(format!(
347 "unknown task action {action:?}; expected select, show, or clear"
348 )));
349 }
350 };
351 Ok(Invocation {
352 command: Command::Task(command),
353 options,
354 switches,
355 })
356}
357
358fn parse_tokens(arguments: impl IntoIterator<Item = String>) -> Result<ParsedTokens, UxError> {
359 let arguments: Vec<String> = arguments.into_iter().collect();
360 let mut options = BTreeMap::new();
361 let mut switches = BTreeSet::new();
362 let mut positionals = Vec::new();
363 let mut index = 0;
364 let mut positional_only = false;
365 while index < arguments.len() {
366 let argument = &arguments[index];
367 if positional_only || !argument.starts_with('-') {
368 positionals.push(argument.clone());
369 index += 1;
370 continue;
371 }
372 if argument == "--" {
373 positional_only = true;
374 index += 1;
375 continue;
376 }
377 let raw = argument
378 .strip_prefix("--")
379 .ok_or_else(|| UxError::Usage(format!("unknown short option {argument:?}")))?;
380 let (raw_name, inline_value) = raw
381 .split_once('=')
382 .map_or((raw, None), |(name, value)| (name, Some(value)));
383 let name = canonical_option(raw_name);
384 if is_switch(name) {
385 if inline_value.is_some() {
386 return Err(UxError::Usage(format!(
387 "--{raw_name} does not take a value"
388 )));
389 }
390 if !switches.insert(name.to_owned()) {
391 return Err(UxError::Usage(format!(
392 "--{raw_name} was supplied more than once"
393 )));
394 }
395 index += 1;
396 continue;
397 }
398 if !is_value_option(name) {
399 return Err(UxError::Usage(format!("unknown option --{raw_name}")));
400 }
401 let value = if let Some(value) = inline_value {
402 value.to_owned()
403 } else {
404 index += 1;
405 arguments
406 .get(index)
407 .filter(|value| !value.starts_with("--"))
408 .cloned()
409 .ok_or_else(|| UxError::Usage(format!("missing value for --{raw_name}")))?
410 };
411 if value.is_empty() {
412 return Err(UxError::Usage(format!(
413 "--{raw_name} requires a non-empty value"
414 )));
415 }
416 if options.insert(name.to_owned(), value).is_some() {
417 return Err(UxError::Usage(format!(
418 "--{raw_name} was supplied more than once"
419 )));
420 }
421 index += 1;
422 }
423 Ok((options, switches, positionals))
424}
425
426fn canonical_option(name: &str) -> &str {
427 match name {
428 "task" => "fg-task",
429 other => other,
430 }
431}
432
433fn is_command(argument: &str) -> bool {
434 matches!(
435 argument,
436 "run"
437 | "resume"
438 | "replace-session"
439 | "inspect"
440 | "doctor"
441 | "login"
442 | "logout"
443 | "status"
444 | "task"
445 | "help"
446 | "version"
447 )
448}
449
450fn is_switch(name: &str) -> bool {
451 matches!(
452 name,
453 "json" | "genui" | "no-mcp" | "new-session" | "mcp-smoke" | "verify-cache"
454 )
455}
456
457fn is_value_option(name: &str) -> bool {
458 matches!(
459 name,
460 "workspace"
461 | "state-dir"
462 | "db"
463 | "goal"
464 | "fg-task"
465 | "candidate-sha256"
466 | "session"
467 | "endpoint"
468 | "model-artifact"
469 | "profile"
470 | "model"
471 | "unqualified-model"
472 | "model-repository"
473 | "artifact-sha256"
474 | "quantization"
475 | "chat-template"
476 | "model-context-tokens"
477 | "api-key-env"
478 | "mcp-config"
479 | "cache-dir"
480 | "runtime-backend"
481 | "runtime-startup-timeout-seconds"
482 | "inference-timeout-seconds"
483 | "verification-timeout-seconds"
484 | "max-model-turns"
485 | "max-tool-calls"
486 | "max-repair-cycles"
487 | "max-wall-seconds"
488 | "max-context-tokens"
489 | "output-reserve-tokens"
490 | "recent-event-limit"
491 | "temperature"
492 | "pause-after-model-turns"
493 | "token"
494 )
495}
496
497fn require_auth_options(
498 options: &BTreeMap<String, String>,
499 switches: &BTreeSet<String>,
500 login: bool,
501) -> Result<(), UxError> {
502 if options.keys().any(|name| !login || name != "token") {
503 return Err(UxError::Usage(if login {
504 "login accepts only --token and --json".to_owned()
505 } else {
506 "this authentication command accepts only --json".to_owned()
507 }));
508 }
509 if switches.iter().any(|name| name != "json") {
510 return Err(UxError::Usage(
511 "authentication commands accept only the --json switch".to_owned(),
512 ));
513 }
514 Ok(())
515}
516
517fn require_no_positionals(positionals: &[String], command: &str) -> Result<(), UxError> {
518 if positionals.is_empty() {
519 Ok(())
520 } else {
521 Err(UxError::Usage(format!(
522 "{command} does not accept positional arguments"
523 )))
524 }
525}
526
527pub fn resolve_paths(
528 options: &BTreeMap<String, String>,
529 current_dir: &Path,
530 environment: &BTreeMap<String, String>,
531) -> Result<WorkspacePaths, UxError> {
532 let requested_workspace = options
533 .get("workspace")
534 .map_or_else(|| current_dir.to_path_buf(), PathBuf::from);
535 let requested_workspace = absolute(&requested_workspace, current_dir);
536 let workspace = fs::canonicalize(&requested_workspace).map_err(UxError::WorkspaceIo)?;
537 let enforce_workspace_boundary =
538 options.contains_key("workspace") || is_implementation_workspace(&workspace);
539 let state_root = options
540 .get("state-dir")
541 .map(PathBuf::from)
542 .or_else(|| environment.get("FALSEGREEN_AGENT_HOME").map(PathBuf::from))
543 .or_else(|| {
544 environment
545 .get("XDG_STATE_HOME")
546 .map(|value| PathBuf::from(value).join("falsegreen-agent"))
547 })
548 .or_else(|| {
549 environment
550 .get("HOME")
551 .map(|value| PathBuf::from(value).join(".local/state/falsegreen-agent"))
552 })
553 .ok_or(UxError::MissingStateHome)?;
554 let state_root = canonicalize_for_containment(&normalize(&absolute(&state_root, current_dir)))?;
555 if enforce_workspace_boundary && is_contained_by(&state_root, &workspace) {
556 return Err(UxError::StateInsideWorkspace(state_root));
557 }
558 let workspace_key = workspace_key(&workspace);
559 let workspace_state = state_root.join("workspaces").join(workspace_key);
560 let database = options.get("db").map(PathBuf::from).map_or_else(
561 || workspace_state.join("events.db"),
562 |path| absolute(&path, current_dir),
563 );
564 let database = canonicalize_for_containment(&normalize(&database))?;
565 if enforce_workspace_boundary && is_contained_by(&database, &workspace) {
566 return Err(UxError::StateInsideWorkspace(database));
567 }
568 let settings = workspace_state.join("settings.json");
569 Ok(WorkspacePaths {
570 workspace,
571 state_root,
572 workspace_state,
573 database,
574 settings,
575 })
576}
577
578#[must_use]
579pub fn process_environment() -> BTreeMap<String, String> {
580 env::vars().collect()
581}
582
583#[must_use]
584pub fn workspace_key(workspace: &Path) -> String {
585 let mut digest = Sha256::new();
586 digest.update(workspace.as_os_str().as_encoded_bytes());
587 let digest = format!("{:x}", digest.finalize());
588 digest[..20].to_owned()
589}
590
591fn absolute(path: &Path, current_dir: &Path) -> PathBuf {
592 if path.is_absolute() {
593 path.to_path_buf()
594 } else {
595 current_dir.join(path)
596 }
597}
598
599fn normalize(path: &Path) -> PathBuf {
600 let mut normalized = PathBuf::new();
601 for component in path.components() {
602 match component {
603 Component::CurDir => {}
604 Component::ParentDir => {
605 normalized.pop();
606 }
607 other => normalized.push(other.as_os_str()),
608 }
609 }
610 normalized
611}
612
613fn is_implementation_workspace(workspace: &Path) -> bool {
614 Workspace::open(workspace).is_ok()
615}
616
617fn is_contained_by(path: &Path, root: &Path) -> bool {
618 path.strip_prefix(root).is_ok()
619}
620
621fn canonicalize_for_containment(path: &Path) -> Result<PathBuf, std::io::Error> {
622 let mut missing = Vec::new();
623 let mut existing = path;
624 while !existing.exists() {
625 let Some(name) = existing.file_name() else {
626 return fs::canonicalize(existing);
627 };
628 missing.push(name.to_os_string());
629 existing = existing.parent().ok_or_else(|| {
630 std::io::Error::new(
631 std::io::ErrorKind::NotFound,
632 format!("no existing parent for {}", path.display()),
633 )
634 })?;
635 }
636 let mut canonical = fs::canonicalize(existing)?;
637 for component in missing.iter().rev() {
638 canonical.push(component);
639 }
640 Ok(canonical)
641}
642
643#[cfg(test)]
644mod tests {
645 use std::collections::BTreeMap;
646 use std::fs;
647
648 use super::{Command, TaskCommand, WorkspaceSettings, is_contained_by, parse, resolve_paths};
649
650 #[test]
651 fn commandless_invocation_is_a_current_directory_run() {
652 let invocation = parse(Vec::<String>::new()).expect("parse");
653 assert_eq!(invocation.command, Command::Run);
654 assert!(invocation.options.is_empty());
655 }
656
657 #[test]
658 fn positional_text_is_the_goal_without_a_run_subcommand() {
659 let invocation = parse(["repair", "the", "parser"].map(str::to_owned)).expect("parse");
660 assert_eq!(invocation.command, Command::Run);
661 assert_eq!(invocation.options["goal"], "repair the parser");
662 }
663
664 #[test]
665 fn parses_resume_json_and_advanced_overrides() {
666 let invocation = parse(
667 [
668 "resume",
669 "session_123",
670 "--json",
671 "--endpoint=http://127.0.0.1:9090",
672 "--model-artifact",
673 "/models/model.gguf",
674 ]
675 .map(str::to_owned),
676 )
677 .expect("parse");
678 assert_eq!(
679 invocation.command,
680 Command::Resume {
681 session_id: Some("session_123".to_owned())
682 }
683 );
684 assert!(invocation.json());
685 assert_eq!(invocation.options["endpoint"], "http://127.0.0.1:9090");
686 }
687
688 #[test]
689 fn genui_is_an_optional_human_output_switch() {
690 let invocation =
691 parse(["run", "--goal", "inspect", "--genui"].map(str::to_owned)).expect("parse");
692 assert!(invocation.switched("genui"));
693 assert!(!invocation.json());
694 }
695
696 #[test]
697 fn replacement_is_explicit_and_requires_candidate_identity() {
698 let candidate = "a".repeat(64);
699 let invocation = parse([
700 "replace-session".to_owned(),
701 "session_failed".to_owned(),
702 "--candidate-sha256".to_owned(),
703 candidate.clone(),
704 "--json".to_owned(),
705 ])
706 .expect("replacement");
707 assert_eq!(
708 invocation.command,
709 Command::ReplaceSession {
710 predecessor_session_id: "session_failed".to_owned()
711 }
712 );
713 assert_eq!(invocation.options["candidate-sha256"], candidate);
714 assert!(invocation.json());
715 assert!(parse(["replace-session", "session_failed"].map(str::to_owned)).is_err());
716 assert!(
717 parse(
718 [
719 "replace-session",
720 "session_failed",
721 "--candidate-sha256",
722 "abc",
723 "--goal",
724 "not allowed",
725 ]
726 .map(str::to_owned),
727 )
728 .is_err()
729 );
730 }
731
732 #[test]
733 fn task_selection_is_deliberate() {
734 let invocation = parse(["task", "select", "task_abc"].map(str::to_owned)).expect("parse");
735 assert_eq!(
736 invocation.command,
737 Command::Task(TaskCommand::Select("task_abc".to_owned()))
738 );
739 }
740
741 #[test]
742 fn authentication_commands_are_explicit_and_secret_options_are_scoped() {
743 let login = parse(["login", "--token", "enrollment-value", "--json"].map(str::to_owned))
744 .expect("login");
745 assert_eq!(login.command, Command::Login);
746 assert_eq!(login.options["token"], "enrollment-value");
747 assert!(login.json());
748 assert_eq!(
749 parse(["status", "--json"].map(str::to_owned))
750 .expect("status")
751 .command,
752 Command::Status
753 );
754 assert!(parse(["logout", "--token", "secret"].map(str::to_owned)).is_err());
755 assert!(parse(["--token", "secret"].map(str::to_owned)).is_err());
756 }
757
758 #[test]
759 fn rejects_unknown_and_duplicate_options() {
760 assert!(parse(["--wat"].map(str::to_owned)).is_err());
761 assert!(parse(["--goal", "a", "--goal", "b"].map(str::to_owned)).is_err());
762 }
763
764 #[test]
765 fn creates_stable_external_workspace_state_paths() {
766 let workspace = tempfile::tempdir().expect("workspace");
767 let state = tempfile::tempdir().expect("state");
768 let options = BTreeMap::from([
769 (
770 "workspace".to_owned(),
771 workspace.path().display().to_string(),
772 ),
773 (
774 "state-dir".to_owned(),
775 state.path().join("agent-state").display().to_string(),
776 ),
777 ]);
778 let paths = resolve_paths(&options, workspace.path(), &BTreeMap::new()).expect("paths");
779 assert_eq!(paths.workspace, fs::canonicalize(workspace.path()).unwrap());
780 assert!(paths.database.ends_with("events.db"));
781 assert!(!paths.database.starts_with(&paths.workspace));
782
783 paths.prepare().expect("prepare");
784 let mut settings = WorkspaceSettings::empty(&paths.workspace);
785 settings.selected_falsegreen_task = Some("task_abc".to_owned());
786 settings.save(&paths).expect("save");
787 assert_eq!(
788 WorkspaceSettings::load(&paths)
789 .expect("load")
790 .selected_falsegreen_task
791 .as_deref(),
792 Some("task_abc")
793 );
794 }
795
796 #[test]
797 fn refuses_state_inside_the_implementation_workspace() {
798 let workspace = tempfile::tempdir().expect("workspace");
799 let options = BTreeMap::from([
800 (
801 "workspace".to_owned(),
802 workspace.path().display().to_string(),
803 ),
804 (
805 "state-dir".to_owned(),
806 workspace.path().join("state").display().to_string(),
807 ),
808 ]);
809 assert!(resolve_paths(&options, workspace.path(), &BTreeMap::new()).is_err());
810 }
811
812 #[test]
813 fn allows_external_xdg_and_temp_state_paths() {
814 let root = tempfile::tempdir().expect("fixture root");
815 let workspace = root.path().join("src/falsegreen-agent");
816 fs::create_dir_all(&workspace).expect("workspace");
817 let xdg_state = root.path().join(".local/state/falsegreen-agent");
818 let xdg_config = root.path().join(".config/falsegreen-agent");
819 let temp_state = root.path().join("tmp/fg-state");
820
821 for state in [&xdg_state, &xdg_config, &temp_state] {
822 let options = BTreeMap::from([
823 ("workspace".to_owned(), workspace.display().to_string()),
824 ("state-dir".to_owned(), state.display().to_string()),
825 ]);
826 let paths = resolve_paths(&options, &workspace, &BTreeMap::new())
827 .expect("external state path should be accepted");
828 assert!(!is_contained_by(&paths.state_root, &paths.workspace));
829 }
830 }
831
832 #[test]
833 fn rejects_exact_nested_and_canonicalized_workspace_state_paths() {
834 let root = tempfile::tempdir().expect("fixture root");
835 let workspace = root.path().join("src/falsegreen-agent");
836 fs::create_dir_all(&workspace).expect("workspace");
837
838 for state in [
839 workspace.clone(),
840 workspace.join("state"),
841 workspace.join(".local/state/falsegreen-agent"),
842 ] {
843 let options = BTreeMap::from([
844 ("workspace".to_owned(), workspace.display().to_string()),
845 ("state-dir".to_owned(), state.display().to_string()),
846 ]);
847 assert!(
848 resolve_paths(&options, &workspace, &BTreeMap::new()).is_err(),
849 "state path should be rejected: {}",
850 state.display()
851 );
852 }
853 }
854
855 #[cfg(unix)]
856 #[test]
857 fn rejects_state_symlink_resolving_inside_workspace() {
858 use std::os::unix::fs::symlink;
859
860 let root = tempfile::tempdir().expect("fixture root");
861 let workspace = root.path().join("src/falsegreen-agent");
862 fs::create_dir_all(&workspace).expect("workspace");
863 fs::create_dir_all(workspace.join("state")).expect("workspace state");
864 let link = root.path().join("state-link");
865 symlink(workspace.join("state"), &link).expect("state symlink");
866 let options = BTreeMap::from([
867 ("workspace".to_owned(), workspace.display().to_string()),
868 ("state-dir".to_owned(), link.display().to_string()),
869 ]);
870 assert!(resolve_paths(&options, &workspace, &BTreeMap::new()).is_err());
871 }
872
873 #[test]
874 fn default_state_under_non_git_home_is_not_misclassified_as_workspace_state() {
875 let root = tempfile::tempdir().expect("home");
876 let environment = BTreeMap::from([
877 ("HOME".to_owned(), root.path().display().to_string()),
878 (
879 "XDG_STATE_HOME".to_owned(),
880 root.path().join(".local/state").display().to_string(),
881 ),
882 ]);
883 let paths = resolve_paths(&BTreeMap::new(), root.path(), &environment)
884 .expect("default XDG state should be accepted outside a Git workspace");
885 assert!(paths.state_root.ends_with("falsegreen-agent"));
886 }
887}