use std::path::Path;
use crate::model::task_id::Token;
use crate::model::track::Track;
use crate::ops::task_ops::{find_max_id_in_tasks, find_max_id_in_track};
#[derive(Debug, Clone, Copy)]
struct OnDisk<'a> {
frame_dir: &'a Path,
track_id: &'a str,
}
#[derive(Debug, Clone, Copy)]
pub struct Mint<'a> {
on_disk: Option<OnDisk<'a>>,
prefix: &'a str,
token: Option<&'a Token>,
}
impl<'a> Mint<'a> {
pub fn new(
frame_dir: &'a Path,
track_id: &'a str,
prefix: &'a str,
token: Option<&'a Token>,
) -> Self {
Mint {
on_disk: Some(OnDisk {
frame_dir,
track_id,
}),
prefix,
token,
}
}
pub fn scan_only(prefix: &'a str, token: Option<&'a Token>) -> Self {
Mint {
on_disk: None,
prefix,
token,
}
}
pub fn prefix(&self) -> &'a str {
self.prefix
}
pub fn token(&self) -> Option<&'a Token> {
self.token
}
pub fn next(&self, track: &Track) -> u32 {
self.next_n(track, 1)
}
pub fn next_n(&self, track: &Track, n: u32) -> u32 {
self.next_n_above(track, 0, n)
}
pub fn next_above(&self, track: &Track, extra_floor: u32) -> u32 {
self.next_n_above(track, extra_floor, 1)
}
fn next_n_above(&self, track: &Track, extra_floor: u32, n: u32) -> u32 {
let floor = self.floor(track).max(extra_floor);
match self.on_disk {
Some(at) => crate::io::ids::reserve(at.frame_dir, self.prefix, self.token, floor, n),
None => floor + 1,
}
}
fn floor(&self, track: &Track) -> u32 {
let prefix_dash = format!("{}-", self.prefix);
let mut max = 0usize;
find_max_id_in_track(track, &prefix_dash, self.token, &mut max);
if let Some(at) = self.on_disk {
let archive = at.frame_dir.join("archive");
let file = format!("{}.md", at.track_id);
archived_tasks_max(&archive.join(&file), &prefix_dash, self.token, &mut max);
archived_track_max(
&archive.join("_tracks").join(&file),
&prefix_dash,
self.token,
&mut max,
);
}
max as u32
}
}
fn archived_tasks_max(path: &Path, prefix_dash: &str, token: Option<&Token>, max: &mut usize) {
let Ok(content) = std::fs::read_to_string(path) else {
return;
};
let lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
let start = lines
.iter()
.position(|l| l.starts_with("- ["))
.unwrap_or(lines.len());
let (tasks, _) = crate::parse::parse_tasks(&lines, start, 0, 0);
find_max_id_in_tasks(&tasks, prefix_dash, token, max);
}
fn archived_track_max(path: &Path, prefix_dash: &str, token: Option<&Token>, max: &mut usize) {
let Ok(content) = std::fs::read_to_string(path) else {
return;
};
let track = crate::parse::parse_track(&content);
find_max_id_in_track(&track, prefix_dash, token, max);
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn track_with(ids: &[&str]) -> Track {
let mut text = String::from("# Demo\n\n## Backlog\n\n");
for id in ids {
text.push_str(&format!("- [ ] `{}` task\n", id));
}
text.push_str("\n## Done\n");
crate::parse::parse_track(&text)
}
fn frame_dir(tmp: &TempDir) -> std::path::PathBuf {
let dir = tmp.path().join("frame");
fs::create_dir_all(dir.join("archive").join("_tracks")).unwrap();
dir
}
#[test]
fn scan_only_mints_above_the_live_maximum() {
let track = track_with(&["DEM-001", "DEM-004"]);
assert_eq!(Mint::scan_only("DEM", None).next(&track), 5);
}
#[test]
fn archived_tasks_hold_their_numbers() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
fs::write(
frame.join("archive").join("demo.md"),
"# Archive — demo\n\n- [x] `DEM-003` task 3\n - resolved: 2026-07-29\n",
)
.unwrap();
let empty = track_with(&[]);
let mint = Mint::new(&frame, "demo", "DEM", None);
assert_eq!(mint.next(&empty), 4);
}
#[test]
fn an_archived_whole_track_holds_its_numbers() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
fs::write(
frame.join("archive").join("_tracks").join("demo.md"),
"# Demo\n\n## Backlog\n\n- [ ] `DEM-012` task\n\n## Done\n",
)
.unwrap();
let empty = track_with(&[]);
assert_eq!(Mint::new(&frame, "demo", "DEM", None).next(&empty), 13);
}
#[test]
fn a_mint_is_durable_once_the_track_no_longer_shows_it() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
let mint = Mint::new(&frame, "demo", "DEM", None);
let track = track_with(&["DEM-001"]);
assert_eq!(mint.next(&track), 2);
assert_eq!(mint.next(&track), 3);
}
#[test]
fn a_batch_reserves_a_contiguous_block() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
let mint = Mint::new(&frame, "demo", "DEM", None);
let track = track_with(&["DEM-002"]);
assert_eq!(mint.next_n(&track, 4), 3); assert_eq!(mint.next(&track), 7);
}
#[test]
fn an_extra_floor_covers_numbers_not_yet_in_the_track() {
let track = track_with(&["DEM-002"]);
let mint = Mint::scan_only("DEM", None);
assert_eq!(mint.next_above(&track, 9), 10);
}
#[test]
fn other_namespaces_are_invisible() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
let track = track_with(&["DEM-b040", "DEM-002"]);
let token = Token::new("b").unwrap();
assert_eq!(Mint::new(&frame, "demo", "DEM", None).next(&track), 3);
assert_eq!(
Mint::new(&frame, "demo", "DEM", Some(&token)).next(&track),
41
);
}
}