use super::model::Mission;
use crate::ledger::store::LedgerStore;
pub fn valid_prefix(prefix: &str) -> bool {
(3..=4).contains(&prefix.len()) && prefix.chars().all(|c| c.is_ascii_uppercase())
}
fn check_prefix(prefix: &str) -> Result<(), String> {
if valid_prefix(prefix) {
Ok(())
} else {
Err(format!(
"error: '{}' is not a mission prefix; a prefix is 3 or 4 capital letters, for example TSK",
prefix
))
}
}
pub fn with_prefix(prefix: &str, id: &str) -> String {
let own = format!("M-{}", prefix);
match id.strip_prefix("M-") {
Some(_) if id == own || id.starts_with(&format!("{}-", own)) => id.to_string(),
Some(rest) => format!("{}-{}", own, rest),
None => id.to_string(),
}
}
fn whole_of(id: &str) -> Option<&str> {
let (whole, last) = id.rsplit_once('-')?;
(!last.is_empty() && last.chars().all(|c| c.is_ascii_digit())).then_some(whole)
}
pub struct Placed {
pub id: String,
pub parent: Option<String>,
pub added: bool,
}
pub fn place(
missions: &[Mission],
prefix: Option<&str>,
id: &str,
parent: Option<&str>,
renamed: Option<&str>,
) -> Result<Placed, String> {
let Some(prefix) = prefix else {
return Ok(Placed {
id: id.to_string(),
parent: parent.map(str::to_string),
added: false,
});
};
let placed = with_prefix(prefix, id);
let added = placed != id;
let mut parent = parent.map(str::to_string);
if let Some(whole) = whole_of(&placed) {
let own = format!("M-{}", prefix);
let exists =
whole != own && Some(whole) != renamed && missions.iter().any(|m| m.id == whole);
if !exists {
return Err(format!(
"error: {} is numbered, so it is a part of {}, and no mission has that ID.\n \
Create {} first, or give the mission a name of its own instead of a number",
placed, whole, whole
));
}
match &parent {
Some(given) if given != whole => {
return Err(format!(
"error: {} is a part of {}, and its parent is {}",
placed, whole, given
));
}
_ => parent = Some(whole.to_string()),
}
}
Ok(Placed {
id: placed,
parent,
added,
})
}
pub fn run(store: &dyn LedgerStore, prefix: Option<&str>) -> Result<(), String> {
match prefix {
Some(prefix) => {
check_prefix(prefix)?;
store.set_mission_prefix(prefix)?;
println!("{}", prefix);
Ok(())
}
None => match store.mission_prefix()? {
Some(prefix) => {
println!("{}", prefix);
Ok(())
}
None => Err(
"error: this ledger has no mission prefix. Choose 3 or 4 capital letters for it, for example from the name of the repo, and run tsk mission prefix <PREFIX>"
.to_string(),
),
},
}
}
pub fn missing_note() -> &'static str {
"note: this ledger has no mission prefix, so the ID is used as typed. Choose 3 or 4 capital letters for the repo and run tsk mission prefix <PREFIX>"
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mission::model::{self, Category};
fn missions(ids: &[&str]) -> Vec<Mission> {
let mut all: Vec<Mission> = Vec::new();
for id in ids {
all.push(model::create(&all, id, "A mission", Category::Operational, None).unwrap());
}
all
}
#[test]
fn a_prefix_is_three_or_four_capital_letters() {
for good in ["TSK", "ABCD"] {
assert!(valid_prefix(good), "{}", good);
}
for bad in ["", "TS", "TSKXY", "tsk", "TS1", "T-K", "TSK "] {
assert!(!valid_prefix(bad), "{}", bad);
}
}
#[test]
fn the_prefix_is_added_once() {
assert_eq!(with_prefix("TSK", "M-BOOT"), "M-TSK-BOOT");
assert_eq!(with_prefix("TSK", "M-BOOT-02"), "M-TSK-BOOT-02");
assert_eq!(with_prefix("TSK", "M-TSK-BOOT"), "M-TSK-BOOT");
assert_eq!(with_prefix("TSK", "M-TSK"), "M-TSK");
assert_eq!(with_prefix("TSK", "M-LAB-01"), "M-TSK-LAB-01");
assert_eq!(with_prefix("TSK", "M-TSKY"), "M-TSK-TSKY");
}
#[test]
fn with_no_prefix_nothing_changes() {
let all = missions(&["M-BOOT"]);
let placed = place(&all, None, "M-BOOT-02", Some("M-X"), None).unwrap();
assert_eq!(placed.id, "M-BOOT-02");
assert_eq!(placed.parent.as_deref(), Some("M-X"));
assert!(!placed.added);
}
#[test]
fn a_new_id_gets_the_prefix_and_a_numbered_one_takes_its_whole_as_parent() {
let all = missions(&["M-TSK-BOOT"]);
let placed = place(&all, Some("TSK"), "M-BOOT-01", None, None).unwrap();
assert_eq!(placed.id, "M-TSK-BOOT-01");
assert_eq!(placed.parent.as_deref(), Some("M-TSK-BOOT"));
assert!(placed.added);
let kept = place(&all, Some("TSK"), "M-TSK-BOOT-01", Some("M-TSK-BOOT"), None).unwrap();
assert!(!kept.added);
let plain = place(&all, Some("TSK"), "M-NAMES", None, None).unwrap();
assert_eq!((plain.id.as_str(), plain.parent), ("M-TSK-NAMES", None));
}
#[test]
fn a_numbered_id_needs_the_mission_it_is_a_part_of() {
let all = missions(&["M-TSK-BOOT"]);
let err = place(&all, Some("TSK"), "M-TSK-ADMIN-03", None, None)
.err()
.unwrap();
assert!(err.contains("part of M-TSK-ADMIN"), "{}", err);
assert!(err.contains("name of its own"), "{}", err);
let err = place(&all, Some("TSK"), "M-TSK-2026", None, None)
.err()
.unwrap();
assert!(err.contains("part of M-TSK,"), "{}", err);
let err = place(
&all,
Some("TSK"),
"M-TSK-BOOT-01",
Some("M-TSK-OTHER"),
None,
)
.err()
.unwrap();
assert!(err.contains("its parent is M-TSK-OTHER"), "{}", err);
}
#[test]
fn a_renamed_mission_is_not_its_own_parent() {
let all = missions(&["M-TSK-BOOT"]);
let err = place(&all, Some("TSK"), "M-TSK-BOOT-01", None, Some("M-TSK-BOOT"))
.err()
.unwrap();
assert!(err.contains("no mission has that ID"), "{}", err);
}
}