use std::path::{Path, PathBuf};
use trusty_mpm::core::trusty_tools_config::{CRATE_NAME, TICKETING_BLOCK_TEMPLATE};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TicketingSeedOutcome {
Created,
Appended,
AlreadyPresent,
AgentsBlockPresent,
}
pub(crate) fn ticketing_config_path() -> Option<PathBuf> {
trusty_common::crate_config::crate_config_path(CRATE_NAME)
}
pub(crate) fn seed_ticketing_block(path: &Path) -> anyhow::Result<TicketingSeedOutcome> {
let existing = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
anyhow::anyhow!("failed to create config dir {}: {e}", parent.display())
})?;
}
write_config(path, TICKETING_BLOCK_TEMPLATE)?;
return Ok(TicketingSeedOutcome::Created);
}
Err(e) => return Err(anyhow::anyhow!("failed to read {}: {e}", path.display())),
};
let doc: serde_yaml::Value = serde_yaml::from_str(&existing).map_err(|e| {
anyhow::anyhow!(
"{} is not valid YAML ({e}) — fix it, then re-run `tm issue seed-config`",
path.display()
)
})?;
match doc.get("agents") {
Some(agents) if agents.get("ticketing").is_some() => {
return Ok(TicketingSeedOutcome::AlreadyPresent);
}
Some(_) => return Ok(TicketingSeedOutcome::AgentsBlockPresent),
None => {}
}
let mut appended = existing;
if !appended.is_empty() {
if !appended.ends_with('\n') {
appended.push('\n');
}
appended.push('\n');
}
appended.push_str(TICKETING_BLOCK_TEMPLATE);
write_config(path, &appended)?;
Ok(TicketingSeedOutcome::Appended)
}
fn write_config(path: &Path, contents: &str) -> anyhow::Result<()> {
trusty_common::crate_config::save_raw_at(path, contents)
.map(|_| ())
.map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display()))
}
pub(crate) fn seed_outcome_result(
outcome: TicketingSeedOutcome,
path: &Path,
) -> anyhow::Result<()> {
match outcome {
TicketingSeedOutcome::Created
| TicketingSeedOutcome::Appended
| TicketingSeedOutcome::AlreadyPresent => Ok(()),
TicketingSeedOutcome::AgentsBlockPresent => Err(anyhow::anyhow!(
"the agents.ticketing block was NOT written to {} — add the block above \
under the existing `agents:` key by hand",
path.display()
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_mpm::core::trusty_tools_config::{
ResolvedTicketing, TrustyToolsConfig, resolve_ticketing,
};
const OPERATOR_CONFIG: &str = "\
# my notes, which a parse-and-reserialize would delete
auto_resume: true
workspace_root_template: ~/work
";
fn config_at(dir: &tempfile::TempDir) -> PathBuf {
dir.path().join(".trusty-tools/trusty-mpm/config.yaml")
}
#[test]
fn seed_creates_the_config_file_with_the_ticketing_block() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = config_at(&tmp);
assert!(!path.exists());
let outcome = seed_ticketing_block(&path).expect("seeds");
assert_eq!(outcome, TicketingSeedOutcome::Created);
let written = std::fs::read_to_string(&path).expect("reads back");
assert_eq!(written, TICKETING_BLOCK_TEMPLATE);
}
#[test]
fn seed_appends_and_preserves_every_existing_byte() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = config_at(&tmp);
std::fs::create_dir_all(path.parent().expect("has a parent")).expect("mkdir");
std::fs::write(&path, OPERATOR_CONFIG).expect("seed file");
let outcome = seed_ticketing_block(&path).expect("seeds");
assert_eq!(outcome, TicketingSeedOutcome::Appended);
let written = std::fs::read_to_string(&path).expect("reads back");
assert!(
written.starts_with(OPERATOR_CONFIG),
"existing bytes were rewritten:\n{written}"
);
assert!(written.ends_with(TICKETING_BLOCK_TEMPLATE), "{written}");
let cfg: TrustyToolsConfig = serde_yaml::from_str(&written).expect("parses");
assert_eq!(cfg.auto_resume, Some(true));
assert_eq!(
resolve_ticketing(&cfg).expect("resolves"),
ResolvedTicketing::default()
);
}
#[test]
fn seed_leaves_an_existing_ticketing_block_untouched() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = config_at(&tmp);
std::fs::create_dir_all(path.parent().expect("has a parent")).expect("mkdir");
let tuned = "agents:\n ticketing:\n milestone_required: false\n";
std::fs::write(&path, tuned).expect("seed file");
let outcome = seed_ticketing_block(&path).expect("seeds");
assert_eq!(outcome, TicketingSeedOutcome::AlreadyPresent);
assert_eq!(
std::fs::read_to_string(&path).expect("reads back"),
tuned,
"an operator's tuned value was overwritten"
);
}
#[test]
fn seed_refuses_to_append_under_an_existing_agents_block() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = config_at(&tmp);
std::fs::create_dir_all(path.parent().expect("has a parent")).expect("mkdir");
let other_agent = "agents:\n someday: {}\n";
std::fs::write(&path, other_agent).expect("seed file");
let outcome = seed_ticketing_block(&path).expect("seeds");
assert_eq!(outcome, TicketingSeedOutcome::AgentsBlockPresent);
assert_eq!(
std::fs::read_to_string(&path).expect("reads back"),
other_agent
);
}
#[test]
fn a_seeded_file_resolves_to_the_builtin_ticketing_defaults() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = config_at(&tmp);
seed_ticketing_block(&path).expect("seeds");
let raw = std::fs::read_to_string(&path).expect("reads back");
let cfg: TrustyToolsConfig = serde_yaml::from_str(&raw).expect("parses");
assert_eq!(
resolve_ticketing(&cfg).expect("resolves"),
ResolvedTicketing::default(),
"seeding must not change the standard"
);
}
#[cfg(unix)]
#[test]
fn seed_writes_atomically_and_never_in_place() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().expect("tempdir");
let path = config_at(&tmp);
let dir = path.parent().expect("has a parent").to_path_buf();
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(&path, OPERATOR_CONFIG).expect("seed file");
assert_eq!(
seed_ticketing_block(&path).expect("seeds"),
TicketingSeedOutcome::Appended
);
let siblings: Vec<PathBuf> = std::fs::read_dir(&dir)
.expect("reads dir")
.map(|e| e.expect("entry").path())
.collect();
assert_eq!(siblings, vec![path.clone()], "a .tmp sibling survived");
std::fs::write(&path, OPERATOR_CONFIG).expect("reset");
let restore = std::fs::metadata(&dir).expect("stat").permissions();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).expect("chmod");
let root_can_still_write = std::fs::write(dir.join("probe"), b"x").is_ok();
let result = if root_can_still_write {
std::fs::remove_file(dir.join("probe")).ok();
None
} else {
Some(seed_ticketing_block(&path))
};
std::fs::set_permissions(&dir, restore).expect("restore");
let Some(result) = result else {
return; };
assert!(result.is_err(), "the write was expected to fail");
assert_eq!(
std::fs::read_to_string(&path).expect("reads back"),
OPERATOR_CONFIG,
"a failed write modified the operator's config in place"
);
}
#[test]
fn only_the_refusal_outcome_exits_nonzero() {
let path = Path::new("/home/bob/.trusty-tools/trusty-mpm/config.yaml");
for ok in [
TicketingSeedOutcome::Created,
TicketingSeedOutcome::Appended,
TicketingSeedOutcome::AlreadyPresent,
] {
assert!(
seed_outcome_result(ok, path).is_ok(),
"{ok:?} must exit 0 — the block is on disk"
);
}
let err = seed_outcome_result(TicketingSeedOutcome::AgentsBlockPresent, path)
.expect_err("the refusal must exit nonzero");
let msg = err.to_string();
assert!(msg.contains("NOT written"), "{msg}");
assert!(msg.contains(&path.display().to_string()), "{msg}");
}
#[test]
fn the_seeded_path_is_the_one_the_loader_reads() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = trusty_common::crate_config::crate_config_path_at(tmp.path(), CRATE_NAME);
assert_eq!(path, config_at(&tmp));
assert_eq!(
ticketing_config_path(),
trusty_common::crate_config::crate_config_path(CRATE_NAME)
);
assert_eq!(
seed_ticketing_block(&path).expect("seeds"),
TicketingSeedOutcome::Created
);
let loaded: TrustyToolsConfig = trusty_common::crate_config::load_at(&path)
.expect("the loader reads it")
.expect("the file is there");
assert!(
loaded
.agents
.as_ref()
.and_then(|a| a.ticketing.as_ref())
.is_some(),
"the loader did not see the seeded block"
);
assert_eq!(
resolve_ticketing(&loaded).expect("resolves"),
ResolvedTicketing::default()
);
}
}