use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::Paths;
use crate::error::Result;
pub const PLAIN_LEDGER_FILENAME: &str = "plain-mode.json";
pub const SUSPECT_SUFFIX: &str = ".suspect";
pub const PLAIN_STUB_BODY: &str = "{\n \"mcpServers\": {}\n}\n";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LedgerEntry {
pub original: PathBuf,
pub suspect: PathBuf,
pub stub_created: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PlainLedger {
pub entries: Vec<LedgerEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EnableRow {
pub original: PathBuf,
pub action: EnableAction,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum EnableAction {
Renamed,
RenamedNoStub,
SkippedAlreadySuspect,
SkippedMissing,
WouldRename,
WouldSkip { reason: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnableOutcome {
pub rows: Vec<EnableRow>,
pub applied: bool,
pub ledger_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RestoreRow {
pub original: PathBuf,
pub action: RestoreAction,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum RestoreAction {
Restored,
SuspectMissing,
WouldRestore,
WouldSkip { reason: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RestoreOutcome {
pub rows: Vec<RestoreRow>,
pub applied: bool,
pub ledger_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlainStatus {
pub active: bool,
pub entries: Vec<LedgerEntry>,
pub ledger_path: PathBuf,
}
pub fn status(paths: &Paths) -> Result<PlainStatus> {
let ledger_path = paths.home.join(PLAIN_LEDGER_FILENAME);
let entries = if ledger_path.exists() {
let body = fs::read_to_string(&ledger_path)?;
serde_json::from_str::<PlainLedger>(&body)?.entries
} else {
Vec::new()
};
Ok(PlainStatus {
active: !entries.is_empty(),
entries,
ledger_path,
})
}
pub fn enable(
paths: &Paths,
targets: &[PathBuf],
dry_run: bool,
write_stub: bool,
) -> Result<EnableOutcome> {
let ledger_path = paths.home.join(PLAIN_LEDGER_FILENAME);
let mut rows = Vec::with_capacity(targets.len());
let mut new_entries = Vec::new();
for target in targets {
let suspect = suspect_path(target);
let action = if !target.exists() {
if dry_run {
EnableAction::WouldSkip {
reason: "target does not exist".into(),
}
} else {
EnableAction::SkippedMissing
}
} else if suspect.exists() {
if dry_run {
EnableAction::WouldSkip {
reason: "suspect backup already present".into(),
}
} else {
EnableAction::SkippedAlreadySuspect
}
} else if dry_run {
EnableAction::WouldRename
} else {
fs::rename(target, &suspect)?;
if write_stub {
fs::write(target, PLAIN_STUB_BODY)?;
}
new_entries.push(LedgerEntry {
original: target.clone(),
suspect: suspect.clone(),
stub_created: write_stub,
});
if write_stub {
EnableAction::Renamed
} else {
EnableAction::RenamedNoStub
}
};
rows.push(EnableRow {
original: target.clone(),
action,
});
}
if !dry_run && !new_entries.is_empty() {
fs::create_dir_all(&paths.home)?;
let mut ledger = if ledger_path.exists() {
let body = fs::read_to_string(&ledger_path)?;
serde_json::from_str::<PlainLedger>(&body).unwrap_or_default()
} else {
PlainLedger::default()
};
ledger.entries.extend(new_entries);
let body = serde_json::to_string_pretty(&ledger)?;
fs::write(&ledger_path, body)?;
}
Ok(EnableOutcome {
rows,
applied: !dry_run,
ledger_path,
})
}
pub fn restore(paths: &Paths, dry_run: bool) -> Result<RestoreOutcome> {
let ledger_path = paths.home.join(PLAIN_LEDGER_FILENAME);
if !ledger_path.exists() {
return Ok(RestoreOutcome {
rows: Vec::new(),
applied: !dry_run,
ledger_path,
});
}
let body = fs::read_to_string(&ledger_path)?;
let ledger: PlainLedger = serde_json::from_str(&body)?;
let mut rows = Vec::with_capacity(ledger.entries.len());
for entry in &ledger.entries {
let action = if !entry.suspect.exists() {
if dry_run {
RestoreAction::WouldSkip {
reason: "suspect file missing".into(),
}
} else {
RestoreAction::SuspectMissing
}
} else if dry_run {
RestoreAction::WouldRestore
} else {
if entry.original.exists() {
fs::remove_file(&entry.original)?;
}
fs::rename(&entry.suspect, &entry.original)?;
RestoreAction::Restored
};
rows.push(RestoreRow {
original: entry.original.clone(),
action,
});
}
if !dry_run {
fs::remove_file(&ledger_path)?;
}
Ok(RestoreOutcome {
rows,
applied: !dry_run,
ledger_path,
})
}
fn suspect_path(original: &Path) -> PathBuf {
let mut s = original.as_os_str().to_os_string();
s.push(SUSPECT_SUFFIX);
PathBuf::from(s)
}
pub fn default_targets() -> Vec<PathBuf> {
vec![PathBuf::from(".mcp.json")]
}
#[cfg(test)]
mod tests {
use super::*;
fn paths_for(tmp: &tempfile::TempDir) -> Paths {
Paths {
home: tmp.path().to_path_buf(),
user_home: tmp.path().to_path_buf(),
}
}
fn write(p: &Path, body: &str) {
std::fs::write(p, body).unwrap();
}
#[test]
fn enable_dry_run_does_not_touch_filesystem() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join(".mcp.json");
write(&target, "{\"mcpServers\":{\"x\":1}}");
let original_body = std::fs::read_to_string(&target).unwrap();
let outcome = enable(&paths_for(&tmp), &[target.clone()], true, true).unwrap();
assert!(!outcome.applied);
assert_eq!(outcome.rows[0].action, EnableAction::WouldRename);
assert!(target.exists());
assert_eq!(std::fs::read_to_string(&target).unwrap(), original_body);
assert!(!suspect_path(&target).exists());
assert!(!outcome.ledger_path.exists());
}
#[test]
fn enable_renames_and_writes_stub() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join(".mcp.json");
write(
&target,
"{\"mcpServers\":{\"original\":{\"command\":\"x\"}}}",
);
let suspect = suspect_path(&target);
let outcome = enable(&paths_for(&tmp), &[target.clone()], false, true).unwrap();
assert!(outcome.applied);
assert_eq!(outcome.rows[0].action, EnableAction::Renamed);
assert!(suspect.exists(), "suspect file must be created");
assert!(target.exists(), "stub must be at original path");
let stub = std::fs::read_to_string(&target).unwrap();
assert_eq!(stub, PLAIN_STUB_BODY);
assert!(outcome.ledger_path.exists());
}
#[test]
fn enable_skips_when_suspect_already_present() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join(".mcp.json");
write(&target, "{}");
write(&suspect_path(&target), "prior-backup");
let outcome = enable(&paths_for(&tmp), &[target.clone()], false, true).unwrap();
assert_eq!(outcome.rows[0].action, EnableAction::SkippedAlreadySuspect);
assert_eq!(
std::fs::read_to_string(suspect_path(&target)).unwrap(),
"prior-backup"
);
assert!(!outcome.ledger_path.exists());
}
#[test]
fn restore_round_trip_returns_original_content() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join(".mcp.json");
let body = "{\"mcpServers\":{\"real\":{\"command\":\"x\"}}}";
write(&target, body);
enable(&paths_for(&tmp), &[target.clone()], false, true).unwrap();
assert_ne!(std::fs::read_to_string(&target).unwrap(), body);
let restore_outcome = restore(&paths_for(&tmp), false).unwrap();
assert!(restore_outcome.applied);
assert_eq!(restore_outcome.rows[0].action, RestoreAction::Restored);
assert_eq!(std::fs::read_to_string(&target).unwrap(), body);
assert!(!suspect_path(&target).exists());
assert!(!restore_outcome.ledger_path.exists());
}
#[test]
fn status_reflects_ledger_state() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join(".mcp.json");
write(&target, "{}");
let before = status(&paths_for(&tmp)).unwrap();
assert!(!before.active);
assert!(before.entries.is_empty());
enable(&paths_for(&tmp), &[target.clone()], false, true).unwrap();
let during = status(&paths_for(&tmp)).unwrap();
assert!(during.active);
assert_eq!(during.entries.len(), 1);
assert_eq!(during.entries[0].original, target);
restore(&paths_for(&tmp), false).unwrap();
let after = status(&paths_for(&tmp)).unwrap();
assert!(!after.active);
assert!(after.entries.is_empty());
}
#[test]
fn restore_with_no_ledger_is_a_noop() {
let tmp = tempfile::tempdir().unwrap();
let outcome = restore(&paths_for(&tmp), false).unwrap();
assert!(outcome.rows.is_empty());
assert!(!outcome.ledger_path.exists());
}
#[test]
fn missing_target_yields_skipped_missing() {
let tmp = tempfile::tempdir().unwrap();
let outcome = enable(
&paths_for(&tmp),
&[tmp.path().join("not-there.json")],
false,
true,
)
.unwrap();
assert_eq!(outcome.rows[0].action, EnableAction::SkippedMissing);
assert!(!outcome.ledger_path.exists());
}
}