Skip to main content

falsegreen_agent/
ux.rs

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}