use super::claude_code::{
append_hook, hook_state, not_object_conflict, read_json, remove_hook, skill_fingerprint_intact,
skill_state, skill_text, unreadable_conflict, HookState, SkillState,
};
use super::json::{self, Value};
use super::tree;
use crate::args::Args;
use crate::failure::Failure;
use crate::output::outln;
use crate::style::Stream;
use std::path::{Path, PathBuf};
const CONFIG_LABEL: &str = ".codex/config.toml";
const HOOKS_LABEL: &str = ".codex/hooks.json";
const SKILL_LABEL: &str = ".agents/skills/vivac-migrate/SKILL.md";
const SESSION_START_COMMAND: &str = "vivac session start --hook";
const SESSION_END_COMMAND: &str = "vivac session end --hook";
const SESSION_PROMPT_COMMAND: &str = "vivac session prompt --hook";
const SESSION_START_MATCHER: &str = "startup|resume|clear|compact";
fn hooks_content() -> String {
format!(
"{{\n \
\"hooks\": {{\n \
\"SessionStart\": [\n \
{{\n \
\"matcher\": \"{SESSION_START_MATCHER}\",\n \
\"hooks\": [\n \
{{ \"type\": \"command\", \"command\": \"{SESSION_START_COMMAND}\" }}\n \
]\n \
}}\n \
],\n \
\"UserPromptSubmit\": [\n \
{{\n \
\"hooks\": [\n \
{{ \"type\": \"command\", \"command\": \"{SESSION_PROMPT_COMMAND}\" }}\n \
]\n \
}}\n \
],\n \
\"Stop\": [\n \
{{\n \
\"hooks\": [\n \
{{ \"type\": \"command\", \"command\": \"{SESSION_END_COMMAND}\" }}\n \
]\n \
}}\n \
]\n \
}}\n\
}}\n"
)
}
const CONFIG_OPEN_MARKER: &str = "# added by vivac setup codex";
const CONFIG_CLOSE_MARKER: &str = "# end of what vivac setup codex added";
const CONFIG_CONTENT: &str = "# added by vivac setup codex\n\
[mcp_servers.vivac]\n\
command = \"vivac\"\n\
args = [\"mcp\"]\n\
# end of what vivac setup codex added\n";
#[derive(Debug)]
enum ConfigState {
Create,
Append,
Already,
}
fn config_state(existing: &str) -> Result<ConfigState, String> {
let lines: Vec<&str> = existing.lines().collect();
let has_open = lines.contains(&CONFIG_OPEN_MARKER);
let has_close = lines.contains(&CONFIG_CLOSE_MARKER);
if has_open && has_close {
return Ok(ConfigState::Already);
}
if has_open != has_close {
return Err(config_marker_conflict(has_open));
}
if lines.iter().any(|&l| l.trim() == "[mcp_servers.vivac]") {
return Err(config_table_conflict());
}
Ok(ConfigState::Append)
}
fn config_marker_conflict(has_open: bool) -> String {
let (missing_word, missing_marker) = if has_open {
("closing", CONFIG_CLOSE_MARKER)
} else {
("opening", CONFIG_OPEN_MARKER)
};
format!(
" {CONFIG_LABEL} has one of setup's own two markers and not the other.\n \
The {missing_word} one is missing:\n {missing_marker}\n \
A half-written block is not repaired by guessing where it ended. Fix it\n \
by hand, or take out the marker that is there, then run setup again."
)
}
fn config_table_conflict() -> String {
format!(
" {CONFIG_LABEL} already has a [mcp_servers.vivac] table that setup did not\n \
write, and setup never rewrites an entry it did not write. Adding ours\n \
below it would declare that table twice, which is a file Codex cannot\n \
read at all. Rename or remove it, then run setup again."
)
}
fn append_config(existing: &str) -> String {
let mut s = existing.to_string();
if !s.ends_with('\n') {
s.push('\n');
}
s.push('\n');
s.push_str(CONFIG_CONTENT);
s
}
enum ConfigUndoState {
Ours,
NotOurs,
HalfMarker(bool),
}
fn config_undo_state(existing: &str) -> ConfigUndoState {
let lines: Vec<&str> = existing.lines().collect();
let has_open = lines.contains(&CONFIG_OPEN_MARKER);
let has_close = lines.contains(&CONFIG_CLOSE_MARKER);
if has_open && has_close {
ConfigUndoState::Ours
} else if has_open != has_close {
ConfigUndoState::HalfMarker(has_open)
} else {
ConfigUndoState::NotOurs
}
}
fn remove_config_block(existing: &str) -> String {
let lines: Vec<&str> = existing.lines().collect();
let open = lines
.iter()
.position(|&l| l == CONFIG_OPEN_MARKER)
.expect("config_undo_state::Ours already confirmed this marker is here");
let close = lines
.iter()
.position(|&l| l == CONFIG_CLOSE_MARKER)
.expect("config_undo_state::Ours already confirmed this marker is here");
let start = if open > 1 && lines[open - 1].is_empty() {
open - 1
} else {
open
};
let mut kept: Vec<&str> = Vec::new();
kept.extend_from_slice(&lines[..start]);
kept.extend_from_slice(&lines[close + 1..]);
let mut s = kept.join("\n");
if !s.is_empty() {
s.push('\n');
}
s
}
struct Paths {
config: PathBuf,
hooks: PathBuf,
skill: PathBuf,
}
fn paths(root: &Path) -> Paths {
Paths {
config: root.join(".codex").join("config.toml"),
hooks: root.join(".codex").join("hooks.json"),
skill: root
.join(".agents")
.join("skills")
.join("vivac-migrate")
.join("SKILL.md"),
}
}
pub fn run(cwd: &Path, a: &Args) -> Result<i32, Failure> {
if a.has("undo") {
return undo(cwd, a);
}
let roots = super::resolve_for_setup(cwd)?;
if let Some(refusal) = super::refuse_home_or_global_store(&roots) {
return Err(refusal);
}
apply(&roots, a)
}
#[allow(clippy::too_many_arguments)]
fn plan_items(
config_state: &ConfigState,
hooks_exists: bool,
start_hook_state: &HookState,
stop_hook_state: &HookState,
prompt_hook_state: &HookState,
start_missing: bool,
stop_missing: bool,
prompt_missing: bool,
skill_file_state: &SkillState,
) -> Vec<super::claude_code::PlanItem> {
use super::claude_code::PlanItem;
let (config_verb, config_what) = match config_state {
ConfigState::Create => ("create", "the \"vivac\" server"),
ConfigState::Append => ("add", "the \"vivac\" server"),
ConfigState::Already => ("keep", "already has the \"vivac\" server"),
};
let mut config_item = PlanItem::new(config_verb, CONFIG_LABEL, config_what);
if !matches!(config_state, ConfigState::Already) {
config_item = config_item.with_sub("run", "vivac mcp");
}
let (hooks_verb, hooks_what) = match (hooks_exists, start_missing, stop_missing, prompt_missing)
{
(_, false, false, false) => ("keep", "already has all three hooks"),
(_, true, false, false) => ("add", "the SessionStart hook"),
(_, false, true, false) => ("add", "the Stop hook"),
(_, false, false, true) => ("add", "the UserPromptSubmit hook"),
(_, true, true, false) | (_, true, false, true) | (_, false, true, true) => {
("add", "two hooks")
}
(false, true, true, true) => ("create", "three hooks"),
(true, true, true, true) => ("add", "three hooks"),
};
let mut hooks_item = PlanItem::new(hooks_verb, HOOKS_LABEL, hooks_what);
match start_hook_state {
HookState::Missing => {
hooks_item = hooks_item.with_sub("SessionStart", SESSION_START_COMMAND)
}
HookState::Different(cmd) => {
hooks_item = hooks_item.with_sub("SessionStart", format!("already runs {cmd}"))
}
HookState::Exact => {}
}
match stop_hook_state {
HookState::Missing => hooks_item = hooks_item.with_sub("Stop", SESSION_END_COMMAND),
HookState::Different(cmd) => {
hooks_item = hooks_item.with_sub("Stop", format!("already runs {cmd}"))
}
HookState::Exact => {}
}
match prompt_hook_state {
HookState::Missing => {
hooks_item = hooks_item.with_sub("UserPromptSubmit", SESSION_PROMPT_COMMAND)
}
HookState::Different(cmd) => {
hooks_item = hooks_item.with_sub("UserPromptSubmit", format!("already runs {cmd}"))
}
HookState::Exact => {}
}
let (skill_verb, skill_what) = match skill_file_state {
SkillState::Missing => ("create", "how an agent brings another memory into vivac"),
SkillState::Replaceable => ("replace", "the copy an earlier vivac wrote"),
SkillState::Same => ("keep", "already there"),
SkillState::Conflict => unreachable!("a skill conflict never reaches the plan"),
};
vec![
config_item,
hooks_item,
PlanItem::new(skill_verb, SKILL_LABEL, skill_what),
]
}
fn skill_conflict() -> String {
format!(
" {SKILL_LABEL} is already there, and either setup did not write it or\n \
it was changed since. setup never overwrites it: move it away, then run\n \
setup again."
)
}
fn apply(roots: &super::Roots, a: &Args) -> Result<i32, Failure> {
let here = &roots.here;
let target = paths(here);
let config_raw = std::fs::read_to_string(&target.config).ok();
let hooks = read_json(&target.hooks);
let skill_raw = std::fs::read_to_string(&target.skill).ok();
let mut conflicts: Vec<String> = Vec::new();
let config_state_result = match &config_raw {
None => Ok(ConfigState::Create),
Some(text) => config_state(text),
};
if let Err(msg) = &config_state_result {
conflicts.push(msg.clone());
}
if let Some((line, col)) = hooks.parse_error {
conflicts.push(unreadable_conflict(HOOKS_LABEL, line, col));
} else if hooks.not_object {
conflicts.push(not_object_conflict(HOOKS_LABEL));
}
let skill_file_state = match &skill_raw {
None => SkillState::Missing,
Some(text) => skill_state(text),
};
if matches!(skill_file_state, SkillState::Conflict) {
conflicts.push(skill_conflict());
}
if !conflicts.is_empty() {
let mut msg = conflicts.join("\n\n");
msg.push_str("\n\n Nothing written.");
return Err(Failure::Model(msg));
}
let config_state = config_state_result.expect("checked above");
let hooks_root = hooks.value.clone().unwrap_or_else(|| Value::object(vec![]));
let start_hook_state = hook_state(&hooks_root, "SessionStart", "start", SESSION_START_COMMAND);
let stop_hook_state = hook_state(&hooks_root, "Stop", "end", SESSION_END_COMMAND);
let prompt_hook_state = hook_state(
&hooks_root,
"UserPromptSubmit",
"prompt",
SESSION_PROMPT_COMMAND,
);
let start_missing = matches!(start_hook_state, HookState::Missing);
let stop_missing = matches!(stop_hook_state, HookState::Missing);
let prompt_missing = matches!(prompt_hook_state, HookState::Missing);
let skill_missing_or_replaceable = matches!(
skill_file_state,
SkillState::Missing | SkillState::Replaceable
);
let config_needs_write = !matches!(config_state, ConfigState::Already);
let hooks_needs_write = start_missing || stop_missing || prompt_missing;
let nothing_to_write =
!config_needs_write && !hooks_needs_write && !skill_missing_or_replaceable;
let plan_block = format!(
"{}{}",
super::claude_code::heading(Stream::Out, "vivac setup codex", here),
super::claude_code::render_items(
Stream::Out,
&plan_items(
&config_state,
hooks.exists,
&start_hook_state,
&stop_hook_state,
&prompt_hook_state,
start_missing,
stop_missing,
prompt_missing,
&skill_file_state,
),
)
);
if a.has("dry-run") {
outln!(
"{}",
super::claude_code::close_with(
&format!("{plan_block}\n{}", super::claude_code::TRAILING_PARAGRAPH),
super::claude_code::DRY_RUN_LINE
)
);
return Ok(0);
}
if nothing_to_write {
tree::note_registry(roots);
outln!(
"{}",
super::claude_code::close_with(
&plan_block,
"Nothing to write: this project is already set up."
)
);
return Ok(0);
}
if !a.has("yes") && !super::stdin_is_terminal() {
return Err(Failure::Model(super::no_terminal_text(
super::Harness::Codex,
a,
)));
}
super::claude_code::print_plan(&format!(
"{plan_block}\n{}",
super::claude_code::TRAILING_PARAGRAPH
));
let proceed = a.has("yes") || super::ask("\nWrite it? [y/N] ");
if !proceed {
outln!("\nNothing written.");
return Ok(0);
}
let mut writes = Vec::new();
match config_state {
ConfigState::Create => {
writes.push(super::PlannedWrite::write(
target.config.clone(),
CONFIG_CONTENT.to_string(),
None,
));
}
ConfigState::Append => {
let existing = config_raw.expect("Append only reached with a file already there");
let rendered = append_config(&existing);
writes.push(super::PlannedWrite::write(
target.config.clone(),
rendered,
Some(existing.into_bytes()),
));
}
ConfigState::Already => {}
}
if hooks_needs_write {
if hooks.exists {
let mut new_hooks = hooks_root.clone();
if start_missing {
append_hook(
&mut new_hooks,
"SessionStart",
SESSION_START_COMMAND,
Some(SESSION_START_MATCHER),
);
}
if stop_missing {
append_hook(&mut new_hooks, "Stop", SESSION_END_COMMAND, None);
}
if prompt_missing {
append_hook(
&mut new_hooks,
"UserPromptSubmit",
SESSION_PROMPT_COMMAND,
None,
);
}
let rendered = json::finalize(
&json::render(&new_hooks, &hooks.indent),
hooks.eol,
hooks.trailing_newline,
);
let before = hooks_root.clone();
writes.push(super::PlannedWrite {
path: target.hooks.clone(),
action: super::Action::Write(rendered),
original: Some(hooks.raw.clone().into_bytes()),
preserved: Some(Box::new(move |updated| json::extends(&before, updated))),
});
} else {
writes.push(super::PlannedWrite::write(
target.hooks.clone(),
hooks_content(),
None,
));
}
}
if skill_missing_or_replaceable {
writes.push(super::PlannedWrite::write(
target.skill.clone(),
skill_text(),
skill_raw.clone().map(String::into_bytes),
));
}
super::commit(&writes)?;
tree::note_registry(roots);
print!("\n{}", written_text(here));
print!("{}", super::claude_code::migrate_advice(roots));
Ok(0)
}
fn undo(here: &Path, a: &Args) -> Result<i32, Failure> {
use super::claude_code::PlanItem;
let target = paths(here);
let config_raw = std::fs::read_to_string(&target.config).ok();
let hooks = read_json(&target.hooks);
let skill_raw = std::fs::read_to_string(&target.skill).ok();
let mut conflicts: Vec<String> = Vec::new();
if let Some((line, col)) = hooks.parse_error {
conflicts.push(unreadable_conflict(HOOKS_LABEL, line, col));
} else if hooks.not_object {
conflicts.push(not_object_conflict(HOOKS_LABEL));
}
if !conflicts.is_empty() {
let mut msg = conflicts.join("\n\n");
msg.push_str("\n\n Nothing written.");
return Err(Failure::Model(msg));
}
let config_state = config_raw.as_deref().map(config_undo_state);
let config_ours = matches!(config_state, Some(ConfigUndoState::Ours));
let hooks_root = hooks.value.clone().unwrap_or_else(|| Value::object(vec![]));
let start_hook_state = hook_state(&hooks_root, "SessionStart", "start", SESSION_START_COMMAND);
let stop_hook_state = hook_state(&hooks_root, "Stop", "end", SESSION_END_COMMAND);
let prompt_hook_state = hook_state(
&hooks_root,
"UserPromptSubmit",
"prompt",
SESSION_PROMPT_COMMAND,
);
let start_ours = matches!(start_hook_state, HookState::Exact);
let stop_ours = matches!(stop_hook_state, HookState::Exact);
let prompt_ours = matches!(prompt_hook_state, HookState::Exact);
let skill_ours = skill_raw.as_deref().is_some_and(skill_fingerprint_intact);
let nothing_to_undo = !config_ours && !start_ours && !stop_ours && !prompt_ours && !skill_ours;
if nothing_to_undo {
outln!("Nothing to undo: none of what setup writes is here.");
return Ok(0);
}
let mut preview = hooks_root.clone();
if start_ours {
remove_hook(&mut preview, "SessionStart", SESSION_START_COMMAND);
}
if stop_ours {
remove_hook(&mut preview, "Stop", SESSION_END_COMMAND);
}
if prompt_ours {
remove_hook(&mut preview, "UserPromptSubmit", SESSION_PROMPT_COMMAND);
}
let hooks_becomes_empty = preview
.as_object()
.is_some_and(|s: &[(String, Value)]| s.is_empty());
let (hooks_verb, hooks_what): (&'static str, &'static str) =
match (start_ours, stop_ours, prompt_ours) {
(true, true, true) if hooks_becomes_empty => (
"remove",
"the three hooks setup wrote; nothing else is left, so it goes",
),
(true, true, true) => ("remove", "the three hooks setup wrote"),
(true, true, false) | (true, false, true) | (false, true, true) => {
("remove", "the two hooks setup wrote")
}
(true, false, false) => ("remove", "the SessionStart hook"),
(false, true, false) => ("remove", "the Stop hook"),
(false, false, true) => ("remove", "the UserPromptSubmit hook"),
(false, false, false) => ("keep", "left as it is"),
};
let config_item = match &config_state {
Some(ConfigUndoState::HalfMarker(has_open)) => PlanItem::new(
"keep",
CONFIG_LABEL,
"left as it is: its marker block is half written",
)
.with_sub(
"missing",
if *has_open {
CONFIG_CLOSE_MARKER
} else {
CONFIG_OPEN_MARKER
},
),
Some(ConfigUndoState::Ours) => {
let existing = config_raw
.as_deref()
.expect("ConfigUndoState::Ours only reached with a file present");
if remove_config_block(existing).trim().is_empty() {
PlanItem::new(
"remove",
CONFIG_LABEL,
"the \"vivac\" server; nothing else is left, so it goes",
)
} else {
PlanItem::new("remove", CONFIG_LABEL, "the \"vivac\" server")
}
}
Some(ConfigUndoState::NotOurs) | None => {
PlanItem::new("keep", CONFIG_LABEL, "left as it is")
}
};
let mut hooks_item = PlanItem::new(hooks_verb, HOOKS_LABEL, hooks_what);
if let HookState::Different(_) = &start_hook_state {
hooks_item = hooks_item.with_sub("SessionStart", "runs vivac another way; left as it is");
}
if let HookState::Different(_) = &stop_hook_state {
hooks_item = hooks_item.with_sub("Stop", "runs vivac another way; left as it is");
}
if let HookState::Different(_) = &prompt_hook_state {
hooks_item =
hooks_item.with_sub("UserPromptSubmit", "runs vivac another way; left as it is");
}
let (skill_verb, skill_what) = if skill_ours {
("remove", "the skill setup wrote")
} else if skill_raw.is_some() {
("keep", "changed since setup wrote it; left as it is")
} else {
("keep", "left as it is")
};
let items = vec![
config_item,
hooks_item,
PlanItem::new(skill_verb, SKILL_LABEL, skill_what),
];
let mut s = super::claude_code::heading(Stream::Out, "vivac setup codex --undo", here);
s.push_str(&super::claude_code::render_items(Stream::Out, &items));
if a.has("dry-run") {
outln!(
"{}",
super::claude_code::close_with(&s, super::claude_code::DRY_RUN_LINE)
);
return Ok(0);
}
if !a.has("yes") && !super::stdin_is_terminal() {
return Err(Failure::Model(super::no_terminal_text(
super::Harness::Codex,
a,
)));
}
super::claude_code::print_plan(&s);
let proceed = a.has("yes") || super::ask("\nUndo it? [y/N] ");
if !proceed {
outln!("\nNothing written.");
return Ok(0);
}
let mut writes = Vec::new();
if config_ours {
let existing = config_raw.expect("config_ours only true with a file present");
let removed = remove_config_block(&existing);
if removed.trim().is_empty() {
writes.push(super::PlannedWrite::delete(
target.config.clone(),
existing.into_bytes(),
));
} else {
writes.push(super::PlannedWrite::write(
target.config.clone(),
removed,
Some(existing.into_bytes()),
));
}
}
if start_ours || stop_ours || prompt_ours {
let original = hooks.raw.clone().into_bytes();
if hooks_becomes_empty {
writes.push(super::PlannedWrite::delete(target.hooks.clone(), original));
} else {
let mut new_hooks = hooks_root.clone();
if start_ours {
remove_hook(&mut new_hooks, "SessionStart", SESSION_START_COMMAND);
}
if stop_ours {
remove_hook(&mut new_hooks, "Stop", SESSION_END_COMMAND);
}
if prompt_ours {
remove_hook(&mut new_hooks, "UserPromptSubmit", SESSION_PROMPT_COMMAND);
}
let rendered = json::finalize(
&json::render(&new_hooks, &hooks.indent),
hooks.eol,
hooks.trailing_newline,
);
let before = hooks_root.clone();
writes.push(super::PlannedWrite {
path: target.hooks.clone(),
action: super::Action::Write(rendered),
original: Some(original),
preserved: Some(Box::new(move |updated| {
json::contained_in(updated, &before)
})),
});
}
}
if skill_ours {
writes.push(super::PlannedWrite::delete(
target.skill.clone(),
skill_raw.clone().unwrap().into_bytes(),
));
}
super::commit(&writes)?;
if skill_ours {
super::claude_code::remove_if_empty(target.skill.parent());
}
outln!("\nUndone. The tree in .vivac/ is untouched.");
Ok(0)
}
const FILES_PARAGRAPH: &str =
"The server entry and the hooks file are plain files in this project: commit them if \
everyone who works here uses vivac, and keep them out of version control if only you \
do.";
fn hook_paragraph() -> String {
format!(
"Each hook not already approved is approved on its own, against its hash, and \
asked again if it changes. Inside Codex, the first time and whenever a hook \
changes:\n\n {}",
crate::style::bold(Stream::Out, "/hooks")
)
}
fn quoted_path(path: &str) -> String {
if !path.contains('\'') {
return format!("'{path}'");
}
let escaped = path.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
fn trusted_paragraph(here: &Path) -> String {
format!(
"Codex reads nothing under .codex/ in this project until the folder is trusted. \
The first time Codex opens it, it asks: say yes. If it does not ask, add this to \
~/.codex/config.toml instead:\n\n {}\n {}",
crate::style::bold(
Stream::Out,
&format!("[projects.{}]", quoted_path(&here.display().to_string()))
),
crate::style::bold(Stream::Out, "trust_level = \"trusted\"")
)
}
const SANDBOX_PARAGRAPH: &str =
"Running setup here again is yours to do from a terminal. Now that .codex/ and \
.agents/ exist, Codex keeps both read-only inside its own sandbox, so an agent \
working in this project cannot write to either.";
fn written_text(here: &Path) -> String {
format!(
"{}\n",
[
crate::style::good(Stream::Out, "Written."),
FILES_PARAGRAPH.to_string(),
trusted_paragraph(here),
hook_paragraph(),
SANDBOX_PARAGRAPH.to_string(),
]
.join("\n\n")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_path_with_no_single_quote_is_wrapped_in_single_quotes_untouched() {
let path = r"C:\Users\someone\project";
assert_eq!(quoted_path(path), r"'C:\Users\someone\project'");
}
#[test]
fn a_path_with_a_single_quote_falls_back_to_an_escaped_basic_string() {
let path = r"C:\Users\o'someone\project";
assert_eq!(quoted_path(path), r#""C:\\Users\\o'someone\\project""#);
}
#[test]
fn the_result_never_leaves_a_bare_backslash_inside_double_quotes() {
for path in [
r"C:\Users\someone\project",
r"C:\Users\o'someone\project",
"/home/someone/project",
"/home/o'someone/project",
] {
let quoted = quoted_path(path);
let Some(inner) = quoted.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else {
continue;
};
let stripped_of_escaped_pairs = inner.replace("\\\\", "");
assert!(
!stripped_of_escaped_pairs.contains('\\'),
"{quoted:?} leaves a backslash TOML would read as a bad escape"
);
}
}
#[test]
fn config_state_reads_both_markers_as_already() {
let existing = format!("{CONFIG_OPEN_MARKER}\nsomething\n{CONFIG_CLOSE_MARKER}\n");
assert!(matches!(config_state(&existing), Ok(ConfigState::Already)));
}
#[test]
fn config_state_reads_neither_marker_as_append() {
assert!(matches!(
config_state("[other]\nkey = 1\n"),
Ok(ConfigState::Append)
));
}
#[test]
fn config_state_rejects_an_opening_marker_with_no_closing_one() {
let existing = format!("{CONFIG_OPEN_MARKER}\nsomething\n");
let err = config_state(&existing).unwrap_err();
assert!(err.contains(CONFIG_LABEL), "{err}");
assert!(err.contains("closing"), "{err}");
}
#[test]
fn config_state_rejects_a_closing_marker_with_no_opening_one() {
let existing = format!("something\n{CONFIG_CLOSE_MARKER}\n");
let err = config_state(&existing).unwrap_err();
assert!(err.contains(CONFIG_LABEL), "{err}");
assert!(err.contains("opening"), "{err}");
}
#[test]
fn config_state_rejects_a_foreign_mcp_servers_vivac_table() {
let existing = "[mcp_servers.vivac]\ncommand = \"something-else\"\n";
let err = config_state(existing).unwrap_err();
assert!(err.contains(CONFIG_LABEL), "{err}");
assert!(err.contains("[mcp_servers.vivac]"), "{err}");
}
#[test]
fn append_config_adds_a_blank_line_then_the_block() {
let existing = "# hand-written\n";
let after = append_config(existing);
assert_eq!(after, format!("# hand-written\n\n{CONFIG_CONTENT}"));
}
#[test]
fn append_config_adds_the_missing_newline_before_the_blank_line() {
let existing = "# hand-written, no trailing newline";
let after = append_config(existing);
assert_eq!(
after,
format!("# hand-written, no trailing newline\n\n{CONFIG_CONTENT}")
);
}
}