use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
pub const MARKER_FILENAME: &str = ".claudette-mission.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Mission {
pub slug: String,
pub path: PathBuf,
pub repo: Option<String>,
pub created_at: i64,
#[serde(default)]
pub ephemeral: bool,
}
fn active_slot() -> &'static Mutex<Option<Mission>> {
static SLOT: OnceLock<Mutex<Option<Mission>>> = OnceLock::new();
SLOT.get_or_init(|| Mutex::new(None))
}
#[must_use]
pub fn missions_root() -> PathBuf {
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".claudette").join("missions")
}
pub fn validate_slug(slug: &str) -> Result<String, String> {
let trimmed = slug.trim();
if trimmed.is_empty() {
return Err("mission: empty slug".to_string());
}
if trimmed.contains("..") {
return Err(format!("mission: slug may not contain '..' ({trimmed})"));
}
if trimmed.contains('/') || trimmed.contains('\\') {
return Err(format!(
"mission: slug must be a single directory name, not a path ({trimmed})"
));
}
if trimmed.contains(':') {
return Err(format!("mission: slug may not contain ':' ({trimmed})"));
}
Ok(trimmed.to_string())
}
#[must_use]
pub fn active_mission() -> Option<Mission> {
active_slot().lock().ok().and_then(|guard| guard.clone())
}
#[must_use]
pub fn active_cwd() -> PathBuf {
if let Some(m) = active_mission() {
return m.path;
}
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
pub fn set_active(mission: Mission) -> Result<(), String> {
let mut guard = active_slot()
.lock()
.map_err(|_| "mission: active slot poisoned".to_string())?;
if let Some(existing) = guard.as_ref() {
return Err(format!(
"mission: '{}' is already active — exit it first with mission_exit",
existing.slug
));
}
*guard = Some(mission);
Ok(())
}
pub fn clear_active() -> Option<String> {
let mut guard = active_slot().lock().ok()?;
guard.take().map(|m| m.slug)
}
pub fn try_bootstrap_local_mission() -> Result<Mission, String> {
let toplevel = match std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
{
Ok(out) if out.status.success() => {
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
if raw.is_empty() {
return Err("not inside a git working tree (empty toplevel)".to_string());
}
PathBuf::from(raw)
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(format!(
"git rev-parse --show-toplevel failed: {}",
stderr.trim().chars().take(160).collect::<String>()
));
}
Err(e) => return Err(format!("git not on PATH: {e}")),
};
if !path_under_permitted_roots(&toplevel) {
return Err(format!(
"git repo at {} is outside $HOME and CLAUDETTE_WORKSPACE — \
set CLAUDETTE_WORKSPACE=\"$(pwd)\" first if you intend forge \
to operate on this tree",
toplevel.display()
));
}
let slug = toplevel
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("local")
.to_string();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
Ok(Mission {
slug,
path: toplevel,
repo: None,
created_at: now,
ephemeral: true,
})
}
#[must_use]
pub fn path_under_permitted_roots(path: &Path) -> bool {
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.ok()
.map(PathBuf::from);
if let Some(home) = home {
if path.starts_with(&home) {
return true;
}
}
if let Ok(ws) = std::env::var("CLAUDETTE_WORKSPACE") {
#[cfg(unix)]
let sep = ':';
#[cfg(not(unix))]
let sep = ';';
for root in ws.split(sep).map(str::trim).filter(|s| !s.is_empty()) {
if path.starts_with(root) {
return true;
}
}
}
false
}
#[must_use]
pub fn marker_path_for(mission_path: &Path) -> PathBuf {
mission_path.join(MARKER_FILENAME)
}
pub fn save_marker(mission: &Mission) -> Result<(), String> {
let path = marker_path_for(&mission.path);
let json = serde_json::to_string_pretty(mission)
.map_err(|e| format!("mission: serialize marker: {e}"))?;
std::fs::write(&path, json)
.map_err(|e| format!("mission: write {} failed: {e}", path.display()))?;
Ok(())
}
pub fn add_marker_to_git_exclude(mission_path: &Path) -> Result<(), String> {
let info_dir = mission_path.join(".git").join("info");
if !info_dir.is_dir() {
if std::fs::create_dir_all(&info_dir).is_err() {
return Ok(());
}
}
let exclude_path = info_dir.join("exclude");
let existing = std::fs::read_to_string(&exclude_path).unwrap_or_default();
if existing
.lines()
.any(|l| l.trim() == MARKER_FILENAME || l.trim() == format!("/{MARKER_FILENAME}"))
{
return Ok(());
}
let mut updated = existing;
if !updated.is_empty() && !updated.ends_with('\n') {
updated.push('\n');
}
updated.push_str(MARKER_FILENAME);
updated.push('\n');
std::fs::write(&exclude_path, updated)
.map_err(|e| format!("mission: write {} failed: {e}", exclude_path.display()))?;
Ok(())
}
pub fn load_marker(mission_path: &Path) -> Result<Mission, String> {
let path = marker_path_for(mission_path);
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("mission: read {} failed: {e}", path.display()))?;
serde_json::from_str(&text).map_err(|e| format!("mission: parse {}: {e}", path.display()))
}
pub fn list_missions() -> Result<Vec<Mission>, String> {
let root = missions_root();
if !root.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
let read = std::fs::read_dir(&root)
.map_err(|e| format!("mission: read {} failed: {e}", root.display()))?;
for entry in read.flatten() {
let p = entry.path();
if !p.is_dir() {
continue;
}
if let Ok(m) = load_marker(&p) {
out.push(m);
}
}
out.sort_by(|a, b| a.slug.cmp(&b.slug));
Ok(out)
}
pub fn list_orphan_slugs() -> Result<Vec<String>, String> {
let root = missions_root();
if !root.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
let read = std::fs::read_dir(&root)
.map_err(|e| format!("mission: read {} failed: {e}", root.display()))?;
for entry in read.flatten() {
let p = entry.path();
if !p.is_dir() {
continue;
}
if load_marker(&p).is_err() {
if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
out.push(name.to_string());
}
}
}
out.sort();
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_under_permitted_roots_accepts_home_subpath() {
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.expect("HOME or USERPROFILE must be set for this test");
let candidate = PathBuf::from(&home).join("subdir").join("repo");
assert!(
path_under_permitted_roots(&candidate),
"{} should be permitted under {}",
candidate.display(),
home
);
}
#[test]
fn path_under_permitted_roots_rejects_system_dir() {
let _guard = crate::test_env_lock();
let prev_ws = std::env::var("CLAUDETTE_WORKSPACE").ok();
std::env::remove_var("CLAUDETTE_WORKSPACE");
#[cfg(unix)]
let bad = PathBuf::from("/etc/something");
#[cfg(not(unix))]
let bad = PathBuf::from("C:\\Windows\\System32");
assert!(
!path_under_permitted_roots(&bad),
"{} must NOT be permitted",
bad.display()
);
if let Some(v) = prev_ws {
std::env::set_var("CLAUDETTE_WORKSPACE", v);
}
}
#[test]
fn path_under_permitted_roots_accepts_workspace_entry() {
let _guard = crate::test_env_lock();
let prev_ws = std::env::var("CLAUDETTE_WORKSPACE").ok();
let root = std::env::temp_dir().join("claudette-workspace-root-test");
std::fs::create_dir_all(&root).unwrap();
std::env::set_var("CLAUDETTE_WORKSPACE", &root);
let candidate = root.join("nested-repo");
assert!(
path_under_permitted_roots(&candidate),
"{} should be permitted via CLAUDETTE_WORKSPACE",
candidate.display()
);
match prev_ws {
Some(v) => std::env::set_var("CLAUDETTE_WORKSPACE", v),
None => std::env::remove_var("CLAUDETTE_WORKSPACE"),
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn try_bootstrap_local_mission_in_this_repo_succeeds() {
match try_bootstrap_local_mission() {
Ok(m) => {
assert!(m.ephemeral, "auto-bootstrapped mission must be ephemeral");
assert!(m.path.is_dir(), "bootstrap path must be a directory");
assert!(m.path.join(".git").exists(), "must be a git repo");
assert!(
m.repo.is_none(),
"ephemeral mission has no GH repo metadata"
);
}
Err(why) => {
assert!(
!why.is_empty(),
"bootstrap error must have a non-empty reason"
);
}
}
}
#[test]
fn validate_slug_accepts_simple_name() {
assert_eq!(
validate_slug("django__issue-12345").unwrap(),
"django__issue-12345"
);
}
#[test]
fn validate_slug_trims_whitespace() {
assert_eq!(validate_slug(" hello ").unwrap(), "hello");
}
#[test]
fn validate_slug_rejects_traversal_and_separators() {
for bad in ["..", "foo/../bar", "a/b", "a\\b", "C:\\evil", "", " "] {
assert!(validate_slug(bad).is_err(), "expected reject for `{bad}`");
}
}
#[test]
fn missions_root_under_home_or_userprofile() {
let root = missions_root();
let display = root.display().to_string();
assert!(
display.contains(".claudette") && display.ends_with("missions"),
"unexpected missions_root: {display}"
);
}
#[test]
fn marker_round_trip() {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let tmp = std::env::temp_dir().join(format!("claudette-mission-test-{nanos}"));
std::fs::create_dir_all(&tmp).unwrap();
let mission = Mission {
slug: "abcc-cleanup".to_string(),
path: tmp.clone(),
repo: Some("mrdushidush/agent-battle-command-center".to_string()),
created_at: 1_700_000_000,
ephemeral: false,
};
save_marker(&mission).expect("save_marker should succeed");
let loaded = load_marker(&tmp).expect("load_marker should succeed");
assert_eq!(loaded, mission);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn load_marker_errors_on_missing_file() {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let tmp = std::env::temp_dir().join(format!("claudette-mission-missing-{nanos}"));
std::fs::create_dir_all(&tmp).unwrap();
let err = load_marker(&tmp).expect_err("expected error on missing marker");
assert!(err.contains("read") || err.contains("failed"), "got: {err}");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn list_missions_skips_dirs_without_markers() {
let out = list_missions().expect("list_missions should not fail");
for m in &out {
assert!(!m.slug.is_empty(), "listed mission has empty slug");
}
}
#[test]
fn list_orphan_slugs_runs_cleanly() {
let orphans = list_orphan_slugs().expect("list_orphan_slugs should not fail");
for s in &orphans {
assert!(!s.is_empty(), "orphan with empty name");
}
let missions = list_missions().expect("list_missions should not fail");
for m in &missions {
assert!(
!orphans.contains(&m.slug),
"{} listed as both mission and orphan",
m.slug
);
}
}
#[test]
fn active_cwd_falls_back_to_process_cwd_when_no_mission() {
if active_mission().is_none() {
let cwd = active_cwd();
assert!(
cwd.is_dir(),
"active_cwd fallback {} not a directory",
cwd.display()
);
}
}
#[test]
fn marker_path_for_appends_filename() {
let p = Path::new("/tmp/foo");
assert_eq!(marker_path_for(p), p.join(MARKER_FILENAME));
}
#[test]
fn marker_filename_is_dotfile() {
assert!(MARKER_FILENAME.starts_with('.'));
}
fn unique_tmp(prefix: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("{prefix}-{nanos}-{seq}"))
}
#[test]
fn add_marker_to_git_exclude_writes_into_fresh_repo() {
let tmp = unique_tmp("claudette-exclude-fresh");
std::fs::create_dir_all(tmp.join(".git").join("info")).unwrap();
add_marker_to_git_exclude(&tmp).expect("should succeed");
let body = std::fs::read_to_string(tmp.join(".git").join("info").join("exclude")).unwrap();
assert!(
body.lines().any(|l| l.trim() == MARKER_FILENAME),
"marker not written into exclude file:\n{body}"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn add_marker_to_git_exclude_preserves_existing_rules() {
let tmp = unique_tmp("claudette-exclude-preserve");
let info = tmp.join(".git").join("info");
std::fs::create_dir_all(&info).unwrap();
std::fs::write(info.join("exclude"), "# user rules\n*.log\nnotes/\n").unwrap();
add_marker_to_git_exclude(&tmp).expect("should succeed");
let body = std::fs::read_to_string(info.join("exclude")).unwrap();
assert!(body.contains("*.log"), "preexisting rules dropped: {body}");
assert!(body.contains("notes/"), "preexisting rules dropped: {body}");
assert!(
body.lines().any(|l| l.trim() == MARKER_FILENAME),
"marker not appended: {body}"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn add_marker_to_git_exclude_is_idempotent() {
let tmp = unique_tmp("claudette-exclude-idem");
std::fs::create_dir_all(tmp.join(".git").join("info")).unwrap();
add_marker_to_git_exclude(&tmp).unwrap();
add_marker_to_git_exclude(&tmp).unwrap();
let body = std::fs::read_to_string(tmp.join(".git").join("info").join("exclude")).unwrap();
let count = body.lines().filter(|l| l.trim() == MARKER_FILENAME).count();
assert_eq!(count, 1, "marker line written more than once:\n{body}");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn add_marker_to_git_exclude_creates_info_dir_if_missing() {
let tmp = unique_tmp("claudette-exclude-noinfo");
std::fs::create_dir_all(tmp.join(".git")).unwrap();
add_marker_to_git_exclude(&tmp).expect("should succeed");
let body = std::fs::read_to_string(tmp.join(".git").join("info").join("exclude")).unwrap();
assert!(body.lines().any(|l| l.trim() == MARKER_FILENAME));
let _ = std::fs::remove_dir_all(&tmp);
}
}