use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::path::{Component, Path, PathBuf};
use crate::workspace::Workspace;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
pub const SETTINGS_SCHEMA_VERSION: u32 = 1;
type ParsedTokens = (BTreeMap<String, String>, BTreeSet<String>, Vec<String>);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
Run,
Resume { session_id: Option<String> },
ReplaceSession { predecessor_session_id: String },
Inspect { session_id: Option<String> },
Doctor,
Login,
Logout,
Status,
Task(TaskCommand),
Help,
Version,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskCommand {
Select(String),
Show,
Clear,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Invocation {
pub command: Command,
pub options: BTreeMap<String, String>,
pub switches: BTreeSet<String>,
}
impl Invocation {
#[must_use]
pub fn json(&self) -> bool {
self.switches.contains("json")
}
#[must_use]
pub fn switched(&self, name: &str) -> bool {
self.switches.contains(name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspacePaths {
pub workspace: PathBuf,
pub state_root: PathBuf,
pub workspace_state: PathBuf,
pub database: PathBuf,
pub settings: PathBuf,
}
impl WorkspacePaths {
pub fn prepare(&self) -> Result<(), UxError> {
fs::create_dir_all(&self.workspace_state)?;
if is_implementation_workspace(&self.workspace) {
let canonical_state = canonicalize_for_containment(&self.workspace_state)?;
if is_contained_by(&canonical_state, &self.workspace) {
return Err(UxError::StateInsideWorkspace(canonical_state));
}
}
if let Some(parent) = self.database.parent() {
fs::create_dir_all(parent)?;
if is_implementation_workspace(&self.workspace) {
let canonical_parent = canonicalize_for_containment(parent)?;
if is_contained_by(&canonical_parent, &self.workspace) {
return Err(UxError::StateInsideWorkspace(canonical_parent));
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceSettings {
pub schema_version: u32,
pub canonical_workspace: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_falsegreen_task: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_config: Option<PathBuf>,
}
impl WorkspaceSettings {
#[must_use]
pub fn empty(workspace: impl Into<PathBuf>) -> Self {
Self {
schema_version: SETTINGS_SCHEMA_VERSION,
canonical_workspace: workspace.into(),
selected_falsegreen_task: None,
mcp_config: None,
}
}
pub fn load(paths: &WorkspacePaths) -> Result<Self, UxError> {
let bytes = match fs::read(&paths.settings) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::empty(&paths.workspace));
}
Err(error) => return Err(error.into()),
};
let settings: Self = serde_json::from_slice(&bytes)?;
if settings.schema_version != SETTINGS_SCHEMA_VERSION {
return Err(UxError::UnsupportedSettingsSchema(settings.schema_version));
}
if settings.canonical_workspace != paths.workspace {
return Err(UxError::SettingsWorkspaceMismatch {
expected: paths.workspace.clone(),
actual: settings.canonical_workspace,
});
}
Ok(settings)
}
pub fn save(&self, paths: &WorkspacePaths) -> Result<(), UxError> {
paths.prepare()?;
let bytes = serde_json::to_vec_pretty(self)?;
let temporary = paths
.settings
.with_extension(format!("json.tmp-{}", std::process::id()));
fs::write(&temporary, bytes)?;
fs::rename(temporary, &paths.settings)?;
Ok(())
}
}
#[derive(Debug, Error)]
pub enum UxError {
#[error("{0}")]
Usage(String),
#[error("workspace path could not be resolved: {0}")]
WorkspaceIo(std::io::Error),
#[error("state path is inside the implementation workspace: {0}")]
StateInsideWorkspace(PathBuf),
#[error("no state home is available; set FALSEGREEN_AGENT_HOME or HOME")]
MissingStateHome,
#[error("workspace settings use unsupported schema version {0}")]
UnsupportedSettingsSchema(u32),
#[error("workspace settings belong to {actual}, not {expected}")]
SettingsWorkspaceMismatch { expected: PathBuf, actual: PathBuf },
#[error("workspace settings are malformed: {0}")]
SettingsJson(#[from] serde_json::Error),
#[error("state I/O failed: {0}")]
Io(#[from] std::io::Error),
}
pub fn parse(arguments: impl IntoIterator<Item = String>) -> Result<Invocation, UxError> {
let mut arguments: Vec<String> = arguments.into_iter().collect();
if arguments
.iter()
.any(|argument| argument == "--help" || argument == "-h")
{
return Ok(Invocation {
command: Command::Help,
options: BTreeMap::new(),
switches: BTreeSet::new(),
});
}
if arguments.as_slice() == ["--version"] || arguments.as_slice() == ["-V"] {
return Ok(Invocation {
command: Command::Version,
options: BTreeMap::new(),
switches: BTreeSet::new(),
});
}
let command_name = arguments
.first()
.is_some_and(|argument| is_command(argument))
.then(|| arguments.remove(0));
if command_name.as_deref() == Some("help") {
return Ok(Invocation {
command: Command::Help,
options: BTreeMap::new(),
switches: BTreeSet::new(),
});
}
if command_name.as_deref() == Some("task") {
return parse_task(arguments);
}
let (mut options, switches, positionals) = parse_tokens(arguments)?;
let command = match command_name.as_deref() {
None | Some("run") => {
if !positionals.is_empty() {
if options.contains_key("goal") {
return Err(UxError::Usage(
"provide the goal either positionally or with --goal, not both".to_owned(),
));
}
options.insert("goal".to_owned(), positionals.join(" "));
}
Command::Run
}
Some("resume") => {
if positionals.len() > 1 {
return Err(UxError::Usage(
"resume accepts at most one session ID".to_owned(),
));
}
let positional = positionals.into_iter().next();
if positional.is_some() && options.contains_key("session") {
return Err(UxError::Usage(
"provide the session either positionally or with --session, not both"
.to_owned(),
));
}
Command::Resume {
session_id: positional.or_else(|| options.get("session").cloned()),
}
}
Some("replace-session") => {
if positionals.len() != 1 || positionals[0].trim().is_empty() {
return Err(UxError::Usage(
"replace-session requires exactly one predecessor session ID".to_owned(),
));
}
if !options.contains_key("candidate-sha256") {
return Err(UxError::Usage(
"replace-session requires --candidate-sha256 SHA256".to_owned(),
));
}
if options.keys().any(|name| {
!matches!(
name.as_str(),
"workspace" | "state-dir" | "db" | "candidate-sha256"
)
}) || switches.iter().any(|name| name != "json")
{
return Err(UxError::Usage(
"replace-session accepts only workspace/state paths, --candidate-sha256, and --json"
.to_owned(),
));
}
Command::ReplaceSession {
predecessor_session_id: positionals[0].clone(),
}
}
Some("inspect") => {
if positionals.len() > 1 {
return Err(UxError::Usage(
"inspect accepts at most one session ID".to_owned(),
));
}
let positional = positionals.into_iter().next();
if positional.is_some() && options.contains_key("session") {
return Err(UxError::Usage(
"provide the session either positionally or with --session, not both"
.to_owned(),
));
}
Command::Inspect {
session_id: positional.or_else(|| options.get("session").cloned()),
}
}
Some("doctor") => {
if !positionals.is_empty() {
return Err(UxError::Usage(
"doctor does not accept positional arguments".to_owned(),
));
}
Command::Doctor
}
Some("login") => {
require_no_positionals(&positionals, "login")?;
require_auth_options(&options, &switches, true)?;
Command::Login
}
Some("logout") => {
require_no_positionals(&positionals, "logout")?;
require_auth_options(&options, &switches, false)?;
Command::Logout
}
Some("status") => {
require_no_positionals(&positionals, "status")?;
require_auth_options(&options, &switches, false)?;
Command::Status
}
Some("version") => Command::Version,
Some(other) => return Err(UxError::Usage(format!("unknown command {other:?}"))),
};
if options.contains_key("token") && command != Command::Login {
return Err(UxError::Usage(
"--token is accepted only by `falsegreen-agent login`".to_owned(),
));
}
if switches
.iter()
.any(|name| matches!(name.as_str(), "mcp-smoke" | "verify-cache"))
&& command != Command::Doctor
{
return Err(UxError::Usage(
"--mcp-smoke and --verify-cache are doctor-only switches".to_owned(),
));
}
Ok(Invocation {
command,
options,
switches,
})
}
fn parse_task(arguments: Vec<String>) -> Result<Invocation, UxError> {
let action = arguments
.first()
.ok_or_else(|| UxError::Usage("task requires select, show, or clear".to_owned()))?
.clone();
let (options, switches, positionals) = parse_tokens(arguments.into_iter().skip(1))?;
let command = match action.as_str() {
"select" => {
if positionals.len() != 1 || positionals[0].trim().is_empty() {
return Err(UxError::Usage(
"task select requires exactly one FalseGreen task ID".to_owned(),
));
}
if positionals[0].chars().any(char::is_whitespace) {
return Err(UxError::Usage(
"a FalseGreen task ID cannot contain whitespace".to_owned(),
));
}
TaskCommand::Select(positionals[0].clone())
}
"show" => {
require_no_positionals(&positionals, "task show")?;
TaskCommand::Show
}
"clear" => {
require_no_positionals(&positionals, "task clear")?;
TaskCommand::Clear
}
_ => {
return Err(UxError::Usage(format!(
"unknown task action {action:?}; expected select, show, or clear"
)));
}
};
Ok(Invocation {
command: Command::Task(command),
options,
switches,
})
}
fn parse_tokens(arguments: impl IntoIterator<Item = String>) -> Result<ParsedTokens, UxError> {
let arguments: Vec<String> = arguments.into_iter().collect();
let mut options = BTreeMap::new();
let mut switches = BTreeSet::new();
let mut positionals = Vec::new();
let mut index = 0;
let mut positional_only = false;
while index < arguments.len() {
let argument = &arguments[index];
if positional_only || !argument.starts_with('-') {
positionals.push(argument.clone());
index += 1;
continue;
}
if argument == "--" {
positional_only = true;
index += 1;
continue;
}
let raw = argument
.strip_prefix("--")
.ok_or_else(|| UxError::Usage(format!("unknown short option {argument:?}")))?;
let (raw_name, inline_value) = raw
.split_once('=')
.map_or((raw, None), |(name, value)| (name, Some(value)));
let name = canonical_option(raw_name);
if is_switch(name) {
if inline_value.is_some() {
return Err(UxError::Usage(format!(
"--{raw_name} does not take a value"
)));
}
if !switches.insert(name.to_owned()) {
return Err(UxError::Usage(format!(
"--{raw_name} was supplied more than once"
)));
}
index += 1;
continue;
}
if !is_value_option(name) {
return Err(UxError::Usage(format!("unknown option --{raw_name}")));
}
let value = if let Some(value) = inline_value {
value.to_owned()
} else {
index += 1;
arguments
.get(index)
.filter(|value| !value.starts_with("--"))
.cloned()
.ok_or_else(|| UxError::Usage(format!("missing value for --{raw_name}")))?
};
if value.is_empty() {
return Err(UxError::Usage(format!(
"--{raw_name} requires a non-empty value"
)));
}
if options.insert(name.to_owned(), value).is_some() {
return Err(UxError::Usage(format!(
"--{raw_name} was supplied more than once"
)));
}
index += 1;
}
Ok((options, switches, positionals))
}
fn canonical_option(name: &str) -> &str {
match name {
"task" => "fg-task",
other => other,
}
}
fn is_command(argument: &str) -> bool {
matches!(
argument,
"run"
| "resume"
| "replace-session"
| "inspect"
| "doctor"
| "login"
| "logout"
| "status"
| "task"
| "help"
| "version"
)
}
fn is_switch(name: &str) -> bool {
matches!(
name,
"json" | "genui" | "no-mcp" | "new-session" | "mcp-smoke" | "verify-cache"
)
}
fn is_value_option(name: &str) -> bool {
matches!(
name,
"workspace"
| "state-dir"
| "db"
| "goal"
| "fg-task"
| "candidate-sha256"
| "session"
| "endpoint"
| "model-artifact"
| "profile"
| "model"
| "unqualified-model"
| "model-repository"
| "artifact-sha256"
| "quantization"
| "chat-template"
| "model-context-tokens"
| "api-key-env"
| "mcp-config"
| "cache-dir"
| "runtime-backend"
| "runtime-startup-timeout-seconds"
| "inference-timeout-seconds"
| "verification-timeout-seconds"
| "max-model-turns"
| "max-tool-calls"
| "max-repair-cycles"
| "max-wall-seconds"
| "max-context-tokens"
| "output-reserve-tokens"
| "recent-event-limit"
| "temperature"
| "pause-after-model-turns"
| "token"
)
}
fn require_auth_options(
options: &BTreeMap<String, String>,
switches: &BTreeSet<String>,
login: bool,
) -> Result<(), UxError> {
if options.keys().any(|name| !login || name != "token") {
return Err(UxError::Usage(if login {
"login accepts only --token and --json".to_owned()
} else {
"this authentication command accepts only --json".to_owned()
}));
}
if switches.iter().any(|name| name != "json") {
return Err(UxError::Usage(
"authentication commands accept only the --json switch".to_owned(),
));
}
Ok(())
}
fn require_no_positionals(positionals: &[String], command: &str) -> Result<(), UxError> {
if positionals.is_empty() {
Ok(())
} else {
Err(UxError::Usage(format!(
"{command} does not accept positional arguments"
)))
}
}
pub fn resolve_paths(
options: &BTreeMap<String, String>,
current_dir: &Path,
environment: &BTreeMap<String, String>,
) -> Result<WorkspacePaths, UxError> {
let requested_workspace = options
.get("workspace")
.map_or_else(|| current_dir.to_path_buf(), PathBuf::from);
let requested_workspace = absolute(&requested_workspace, current_dir);
let workspace = fs::canonicalize(&requested_workspace).map_err(UxError::WorkspaceIo)?;
let enforce_workspace_boundary =
options.contains_key("workspace") || is_implementation_workspace(&workspace);
let state_root = options
.get("state-dir")
.map(PathBuf::from)
.or_else(|| environment.get("FALSEGREEN_AGENT_HOME").map(PathBuf::from))
.or_else(|| {
environment
.get("XDG_STATE_HOME")
.map(|value| PathBuf::from(value).join("falsegreen-agent"))
})
.or_else(|| {
environment
.get("HOME")
.map(|value| PathBuf::from(value).join(".local/state/falsegreen-agent"))
})
.ok_or(UxError::MissingStateHome)?;
let state_root = canonicalize_for_containment(&normalize(&absolute(&state_root, current_dir)))?;
if enforce_workspace_boundary && is_contained_by(&state_root, &workspace) {
return Err(UxError::StateInsideWorkspace(state_root));
}
let workspace_key = workspace_key(&workspace);
let workspace_state = state_root.join("workspaces").join(workspace_key);
let database = options.get("db").map(PathBuf::from).map_or_else(
|| workspace_state.join("events.db"),
|path| absolute(&path, current_dir),
);
let database = canonicalize_for_containment(&normalize(&database))?;
if enforce_workspace_boundary && is_contained_by(&database, &workspace) {
return Err(UxError::StateInsideWorkspace(database));
}
let settings = workspace_state.join("settings.json");
Ok(WorkspacePaths {
workspace,
state_root,
workspace_state,
database,
settings,
})
}
#[must_use]
pub fn process_environment() -> BTreeMap<String, String> {
env::vars().collect()
}
#[must_use]
pub fn workspace_key(workspace: &Path) -> String {
let mut digest = Sha256::new();
digest.update(workspace.as_os_str().as_encoded_bytes());
let digest = format!("{:x}", digest.finalize());
digest[..20].to_owned()
}
fn absolute(path: &Path, current_dir: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
current_dir.join(path)
}
}
fn normalize(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
fn is_implementation_workspace(workspace: &Path) -> bool {
Workspace::open(workspace).is_ok()
}
fn is_contained_by(path: &Path, root: &Path) -> bool {
path.strip_prefix(root).is_ok()
}
fn canonicalize_for_containment(path: &Path) -> Result<PathBuf, std::io::Error> {
let mut missing = Vec::new();
let mut existing = path;
while !existing.exists() {
let Some(name) = existing.file_name() else {
return fs::canonicalize(existing);
};
missing.push(name.to_os_string());
existing = existing.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("no existing parent for {}", path.display()),
)
})?;
}
let mut canonical = fs::canonicalize(existing)?;
for component in missing.iter().rev() {
canonical.push(component);
}
Ok(canonical)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::fs;
use super::{Command, TaskCommand, WorkspaceSettings, is_contained_by, parse, resolve_paths};
#[test]
fn commandless_invocation_is_a_current_directory_run() {
let invocation = parse(Vec::<String>::new()).expect("parse");
assert_eq!(invocation.command, Command::Run);
assert!(invocation.options.is_empty());
}
#[test]
fn positional_text_is_the_goal_without_a_run_subcommand() {
let invocation = parse(["repair", "the", "parser"].map(str::to_owned)).expect("parse");
assert_eq!(invocation.command, Command::Run);
assert_eq!(invocation.options["goal"], "repair the parser");
}
#[test]
fn parses_resume_json_and_advanced_overrides() {
let invocation = parse(
[
"resume",
"session_123",
"--json",
"--endpoint=http://127.0.0.1:9090",
"--model-artifact",
"/models/model.gguf",
]
.map(str::to_owned),
)
.expect("parse");
assert_eq!(
invocation.command,
Command::Resume {
session_id: Some("session_123".to_owned())
}
);
assert!(invocation.json());
assert_eq!(invocation.options["endpoint"], "http://127.0.0.1:9090");
}
#[test]
fn genui_is_an_optional_human_output_switch() {
let invocation =
parse(["run", "--goal", "inspect", "--genui"].map(str::to_owned)).expect("parse");
assert!(invocation.switched("genui"));
assert!(!invocation.json());
}
#[test]
fn replacement_is_explicit_and_requires_candidate_identity() {
let candidate = "a".repeat(64);
let invocation = parse([
"replace-session".to_owned(),
"session_failed".to_owned(),
"--candidate-sha256".to_owned(),
candidate.clone(),
"--json".to_owned(),
])
.expect("replacement");
assert_eq!(
invocation.command,
Command::ReplaceSession {
predecessor_session_id: "session_failed".to_owned()
}
);
assert_eq!(invocation.options["candidate-sha256"], candidate);
assert!(invocation.json());
assert!(parse(["replace-session", "session_failed"].map(str::to_owned)).is_err());
assert!(
parse(
[
"replace-session",
"session_failed",
"--candidate-sha256",
"abc",
"--goal",
"not allowed",
]
.map(str::to_owned),
)
.is_err()
);
}
#[test]
fn task_selection_is_deliberate() {
let invocation = parse(["task", "select", "task_abc"].map(str::to_owned)).expect("parse");
assert_eq!(
invocation.command,
Command::Task(TaskCommand::Select("task_abc".to_owned()))
);
}
#[test]
fn authentication_commands_are_explicit_and_secret_options_are_scoped() {
let login = parse(["login", "--token", "enrollment-value", "--json"].map(str::to_owned))
.expect("login");
assert_eq!(login.command, Command::Login);
assert_eq!(login.options["token"], "enrollment-value");
assert!(login.json());
assert_eq!(
parse(["status", "--json"].map(str::to_owned))
.expect("status")
.command,
Command::Status
);
assert!(parse(["logout", "--token", "secret"].map(str::to_owned)).is_err());
assert!(parse(["--token", "secret"].map(str::to_owned)).is_err());
}
#[test]
fn rejects_unknown_and_duplicate_options() {
assert!(parse(["--wat"].map(str::to_owned)).is_err());
assert!(parse(["--goal", "a", "--goal", "b"].map(str::to_owned)).is_err());
}
#[test]
fn creates_stable_external_workspace_state_paths() {
let workspace = tempfile::tempdir().expect("workspace");
let state = tempfile::tempdir().expect("state");
let options = BTreeMap::from([
(
"workspace".to_owned(),
workspace.path().display().to_string(),
),
(
"state-dir".to_owned(),
state.path().join("agent-state").display().to_string(),
),
]);
let paths = resolve_paths(&options, workspace.path(), &BTreeMap::new()).expect("paths");
assert_eq!(paths.workspace, fs::canonicalize(workspace.path()).unwrap());
assert!(paths.database.ends_with("events.db"));
assert!(!paths.database.starts_with(&paths.workspace));
paths.prepare().expect("prepare");
let mut settings = WorkspaceSettings::empty(&paths.workspace);
settings.selected_falsegreen_task = Some("task_abc".to_owned());
settings.save(&paths).expect("save");
assert_eq!(
WorkspaceSettings::load(&paths)
.expect("load")
.selected_falsegreen_task
.as_deref(),
Some("task_abc")
);
}
#[test]
fn refuses_state_inside_the_implementation_workspace() {
let workspace = tempfile::tempdir().expect("workspace");
let options = BTreeMap::from([
(
"workspace".to_owned(),
workspace.path().display().to_string(),
),
(
"state-dir".to_owned(),
workspace.path().join("state").display().to_string(),
),
]);
assert!(resolve_paths(&options, workspace.path(), &BTreeMap::new()).is_err());
}
#[test]
fn allows_external_xdg_and_temp_state_paths() {
let root = tempfile::tempdir().expect("fixture root");
let workspace = root.path().join("src/falsegreen-agent");
fs::create_dir_all(&workspace).expect("workspace");
let xdg_state = root.path().join(".local/state/falsegreen-agent");
let xdg_config = root.path().join(".config/falsegreen-agent");
let temp_state = root.path().join("tmp/fg-state");
for state in [&xdg_state, &xdg_config, &temp_state] {
let options = BTreeMap::from([
("workspace".to_owned(), workspace.display().to_string()),
("state-dir".to_owned(), state.display().to_string()),
]);
let paths = resolve_paths(&options, &workspace, &BTreeMap::new())
.expect("external state path should be accepted");
assert!(!is_contained_by(&paths.state_root, &paths.workspace));
}
}
#[test]
fn rejects_exact_nested_and_canonicalized_workspace_state_paths() {
let root = tempfile::tempdir().expect("fixture root");
let workspace = root.path().join("src/falsegreen-agent");
fs::create_dir_all(&workspace).expect("workspace");
for state in [
workspace.clone(),
workspace.join("state"),
workspace.join(".local/state/falsegreen-agent"),
] {
let options = BTreeMap::from([
("workspace".to_owned(), workspace.display().to_string()),
("state-dir".to_owned(), state.display().to_string()),
]);
assert!(
resolve_paths(&options, &workspace, &BTreeMap::new()).is_err(),
"state path should be rejected: {}",
state.display()
);
}
}
#[cfg(unix)]
#[test]
fn rejects_state_symlink_resolving_inside_workspace() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().expect("fixture root");
let workspace = root.path().join("src/falsegreen-agent");
fs::create_dir_all(&workspace).expect("workspace");
fs::create_dir_all(workspace.join("state")).expect("workspace state");
let link = root.path().join("state-link");
symlink(workspace.join("state"), &link).expect("state symlink");
let options = BTreeMap::from([
("workspace".to_owned(), workspace.display().to_string()),
("state-dir".to_owned(), link.display().to_string()),
]);
assert!(resolve_paths(&options, &workspace, &BTreeMap::new()).is_err());
}
#[test]
fn default_state_under_non_git_home_is_not_misclassified_as_workspace_state() {
let root = tempfile::tempdir().expect("home");
let environment = BTreeMap::from([
("HOME".to_owned(), root.path().display().to_string()),
(
"XDG_STATE_HOME".to_owned(),
root.path().join(".local/state").display().to_string(),
),
]);
let paths = resolve_paths(&BTreeMap::new(), root.path(), &environment)
.expect("default XDG state should be accepted outside a Git workspace");
assert!(paths.state_root.ends_with("falsegreen-agent"));
}
}