use std::path::Path;
use crate::error::JoyError;
use crate::model::item::{item_filename, Item};
use crate::store;
pub fn load_items(root: &Path) -> Result<Vec<Item>, JoyError> {
let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
if !items_dir.is_dir() {
return Ok(Vec::new());
}
let mut items = Vec::new();
let mut entries: Vec<_> = std::fs::read_dir(&items_dir)
.map_err(|e| JoyError::ReadFile {
path: items_dir.clone(),
source: e,
})?
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.is_some_and(|ext| ext == "yaml" || ext == "yml")
})
.collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let item: Item = store::read_yaml(&entry.path())?;
items.push(item);
}
normalize_id_refs(&mut items);
let milestone_ids: Vec<String> = crate::milestones::load_milestones(root)
.map(|list| list.into_iter().map(|m| m.id).collect())
.unwrap_or_default();
normalize_milestone_refs(&mut items, &milestone_ids);
Ok(items)
}
fn short_form(full_id: &str) -> Option<&str> {
let last_dash = full_id.rfind('-')?;
let suffix = &full_id[last_dash + 1..];
if suffix.len() != 2 || u8::from_str_radix(suffix, 16).is_err() {
return None;
}
let prefix = &full_id[..last_dash];
let prev_dash = prefix.rfind('-')?;
let middle = &prefix[prev_dash + 1..];
if middle.len() == 4 && u16::from_str_radix(middle, 16).is_ok() {
Some(prefix)
} else {
None
}
}
fn milestone_short_form(full_id: &str) -> Option<&str> {
let last_dash = full_id.rfind('-')?;
let suffix = &full_id[last_dash + 1..];
if suffix.len() != 2 || u8::from_str_radix(suffix, 16).is_err() {
return None;
}
let prefix = &full_id[..last_dash];
if prefix.contains("-MS-") {
Some(prefix)
} else {
None
}
}
fn normalize_milestone_refs(items: &mut [Item], milestone_ids: &[String]) {
use std::collections::HashMap;
let mut map: HashMap<String, Option<String>> = HashMap::new();
for ms_id in milestone_ids {
if let Some(short) = milestone_short_form(ms_id) {
map.entry(short.to_string())
.and_modify(|e| *e = None)
.or_insert_with(|| Some(ms_id.clone()));
}
}
for item in items.iter_mut() {
if let Some(ms) = item.milestone.as_deref() {
if let Some(Some(full)) = map.get(ms) {
item.milestone = Some(full.clone());
}
}
}
}
fn normalize_id_refs(items: &mut [Item]) {
use std::collections::HashMap;
let mut map: HashMap<String, Option<String>> = HashMap::new();
for item in items.iter() {
if let Some(short) = short_form(&item.id) {
map.entry(short.to_string())
.and_modify(|e| *e = None)
.or_insert_with(|| Some(item.id.clone()));
}
}
for item in items.iter_mut() {
if let Some(p) = item.parent.as_deref() {
if let Some(Some(full)) = map.get(p) {
item.parent = Some(full.clone());
}
}
for dep in &mut item.deps {
if let Some(Some(full)) = map.get(dep.as_str()) {
*dep = full.clone();
}
}
}
}
pub fn save_item(root: &Path, item: &Item) -> Result<(), JoyError> {
let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
let filename = item_filename(&item.id, &item.title);
let path = items_dir.join(&filename);
store::write_yaml(&path, item)?;
let rel = format!("{}/{}/{}", store::JOY_DIR, store::ITEMS_DIR, filename);
crate::git_ops::auto_git_add(root, &[&rel]);
Ok(())
}
pub fn next_id(root: &Path, acronym: &str, title: &str) -> Result<String, JoyError> {
let prefix = acronym;
let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
if !items_dir.is_dir() {
let suffix = title_hash_suffix(title);
return Ok(format!("{prefix}-0001-{suffix}"));
}
let mut max_num: u16 = 0;
let entries = std::fs::read_dir(&items_dir).map_err(|e| JoyError::ReadFile {
path: items_dir.clone(),
source: e,
})?;
for entry in entries.filter_map(|e| e.ok()) {
let name = entry.file_name();
let name = name.to_string_lossy();
if let Some(hex_part) = name.strip_prefix(&format!("{prefix}-")) {
if let Some(hex_str) = hex_part.get(..4) {
if let Ok(num) = u16::from_str_radix(hex_str, 16) {
max_num = max_num.max(num);
}
}
}
}
let next = max_num.checked_add(1).ok_or_else(|| {
JoyError::Other(format!("{prefix} ID space exhausted (max {prefix}-FFFF)"))
})?;
let suffix = title_hash_suffix(title);
Ok(format!("{prefix}-{next:04X}-{suffix}"))
}
pub fn title_hash_suffix(title: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(title.as_bytes());
let hash = hasher.finalize();
format!("{:02X}", hash[0])
}
pub fn find_item_file(root: &Path, id: &str) -> Result<std::path::PathBuf, JoyError> {
let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
let id_upper = id.to_uppercase();
let entries: Vec<_> = std::fs::read_dir(&items_dir)
.map_err(|e| JoyError::ReadFile {
path: items_dir.clone(),
source: e,
})?
.filter_map(|e| e.ok())
.collect();
let exact_prefix = format!("{}-", id_upper);
for entry in &entries {
let name = entry.file_name();
let name_upper = name.to_string_lossy().to_uppercase();
if name_upper.starts_with(&exact_prefix) {
return Ok(entry.path());
}
}
let short_prefix = format!("{}-", id_upper);
let mut matches: Vec<std::path::PathBuf> = Vec::new();
for entry in &entries {
let name = entry.file_name();
let name_upper = name.to_string_lossy().to_uppercase();
if name_upper.starts_with(&short_prefix) {
matches.push(entry.path());
}
}
match matches.len() {
0 => Err(JoyError::ItemNotFound(id.to_string())),
1 => Ok(matches.into_iter().next().unwrap()),
_ => {
let ids: Vec<String> = matches
.iter()
.filter_map(|p| {
let name = p.file_name()?.to_string_lossy().to_string();
extract_full_id(&name)
})
.collect();
Err(JoyError::Other(format!("ambiguous ID: {}", ids.join(", "))))
}
}
}
fn extract_full_id(filename: &str) -> Option<String> {
let name = filename
.strip_suffix(".yaml")
.or_else(|| filename.strip_suffix(".yml"))?;
let parts: Vec<&str> = name.splitn(2, '-').collect();
if parts.len() < 2 {
return None;
}
let acronym = parts[0];
let rest = parts[1];
if rest.len() >= 7 && rest.as_bytes()[4] == b'-' {
let hex4 = &rest[..4];
let maybe_suffix = &rest[5..7];
if u16::from_str_radix(hex4, 16).is_ok()
&& maybe_suffix.len() == 2
&& u8::from_str_radix(maybe_suffix, 16).is_ok()
&& (rest.len() == 7 || rest.as_bytes()[7] == b'-')
{
return Some(format!("{}-{}-{}", acronym, hex4, maybe_suffix).to_uppercase());
}
}
let hex4 = &rest[..4.min(rest.len())];
if hex4.len() == 4 && u16::from_str_radix(hex4, 16).is_ok() {
return Some(format!("{}-{}", acronym, hex4).to_uppercase());
}
None
}
pub fn load_item(root: &Path, id: &str) -> Result<Item, JoyError> {
let path = find_item_file(root, id)?;
let target_id: String = store::read_yaml::<Item>(&path)?.id;
let items = load_items(root)?;
items
.into_iter()
.find(|i| i.id == target_id)
.ok_or(JoyError::ItemNotFound(target_id))
}
pub fn delete_item(root: &Path, id: &str) -> Result<Item, JoyError> {
let path = find_item_file(root, id)?;
let item: Item = store::read_yaml(&path)?;
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
std::fs::remove_file(&path).map_err(|e| JoyError::WriteFile { path, source: e })?;
crate::git_ops::auto_git_add(root, &[&rel]);
Ok(item)
}
pub fn remove_references(root: &Path, deleted_id: &str) -> Result<Vec<String>, JoyError> {
let items = load_items(root)?;
let mut updated = Vec::new();
for mut item in items {
let mut changed = false;
if item.deps.contains(&deleted_id.to_string()) {
item.deps.retain(|d| d != deleted_id);
changed = true;
}
if item.parent.as_deref() == Some(deleted_id) {
item.parent = None;
changed = true;
}
if changed {
item.updated = chrono::Utc::now();
update_item(root, &item)?;
updated.push(item.id.clone());
}
}
Ok(updated)
}
pub fn detect_cycle(
root: &Path,
item_id: &str,
new_dep_id: &str,
) -> Result<Option<Vec<String>>, JoyError> {
let items = load_items(root)?;
let mut visited = vec![item_id.to_string()];
if find_cycle(&items, new_dep_id, &mut visited) {
visited.push(new_dep_id.to_string());
Ok(Some(visited))
} else {
Ok(None)
}
}
fn find_cycle(items: &[Item], current: &str, visited: &mut Vec<String>) -> bool {
if visited.contains(¤t.to_string()) {
return true;
}
if let Some(item) = items.iter().find(|i| i.id == current) {
visited.push(current.to_string());
for dep in &item.deps {
if find_cycle(items, dep, visited) {
return true;
}
}
visited.pop();
}
false
}
pub fn update_item(root: &Path, item: &Item) -> Result<(), JoyError> {
let old_path = find_item_file(root, &item.id)?;
save_item(root, item)?;
let new_path = store::joy_dir(root)
.join(store::ITEMS_DIR)
.join(item_filename(&item.id, &item.title));
if old_path != new_path {
let _ = std::fs::remove_file(&old_path);
let old_rel = old_path
.strip_prefix(root)
.unwrap_or(&old_path)
.to_string_lossy()
.to_string();
crate::git_ops::auto_git_add(root, &[&old_rel]);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::item::{ItemType, Priority};
use tempfile::tempdir;
fn setup_project(dir: &Path) {
let joy_dir = dir.join(".joy");
std::fs::create_dir_all(joy_dir.join("items")).unwrap();
}
#[test]
fn next_id_first_item() {
let dir = tempdir().unwrap();
setup_project(dir.path());
let id = next_id(dir.path(), "JOY", "Test item").unwrap();
assert!(id.starts_with("JOY-0001-"), "got: {id}");
assert_eq!(id.len(), 11); }
#[test]
fn next_id_increments() {
let dir = tempdir().unwrap();
setup_project(dir.path());
let item = Item::new(
"JOY-0001".into(),
"First".into(),
ItemType::Task,
Priority::Low,
vec![],
);
save_item(dir.path(), &item).unwrap();
let id = next_id(dir.path(), "JOY", "Second item").unwrap();
assert!(id.starts_with("JOY-0002-"), "got: {id}");
}
#[test]
fn next_id_skips_gaps() {
let dir = tempdir().unwrap();
setup_project(dir.path());
let item1 = Item::new(
"JOY-0001".into(),
"First".into(),
ItemType::Task,
Priority::Low,
vec![],
);
save_item(dir.path(), &item1).unwrap();
let item3 = Item::new(
"JOY-0003".into(),
"Third".into(),
ItemType::Task,
Priority::Low,
vec![],
);
save_item(dir.path(), &item3).unwrap();
let id = next_id(dir.path(), "JOY", "Fourth item").unwrap();
assert!(id.starts_with("JOY-0004-"), "got: {id}");
}
#[test]
fn next_id_same_title_same_suffix() {
let dir = tempdir().unwrap();
setup_project(dir.path());
let id1 = next_id(dir.path(), "JOY", "Same title").unwrap();
let suffix1 = &id1[9..];
let id2_suffix = title_hash_suffix("Same title");
assert_eq!(suffix1, id2_suffix);
}
#[test]
fn next_id_different_titles_different_suffixes() {
let suffix_a = title_hash_suffix("Fix login bug");
let suffix_b = title_hash_suffix("Add roadmap feature");
assert_ne!(suffix_a, suffix_b);
}
#[test]
fn next_id_increments_past_new_format() {
let dir = tempdir().unwrap();
setup_project(dir.path());
let item = Item::new(
"JOY-0005-A3".into(),
"New format".into(),
ItemType::Task,
Priority::Low,
vec![],
);
save_item(dir.path(), &item).unwrap();
let id = next_id(dir.path(), "JOY", "Next item").unwrap();
assert!(id.starts_with("JOY-0006-"), "got: {id}");
}
#[test]
fn load_items_empty() {
let dir = tempdir().unwrap();
setup_project(dir.path());
let items = load_items(dir.path()).unwrap();
assert!(items.is_empty());
}
#[test]
fn save_and_load_item() {
let dir = tempdir().unwrap();
setup_project(dir.path());
let item = Item::new(
"JOY-0001".into(),
"Test item".into(),
ItemType::Story,
Priority::High,
vec![],
);
save_item(dir.path(), &item).unwrap();
let items = load_items(dir.path()).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].id, "JOY-0001");
assert_eq!(items[0].title, "Test item");
}
#[test]
fn load_items_sorted() {
let dir = tempdir().unwrap();
setup_project(dir.path());
let item2 = Item::new(
"JOY-0002".into(),
"Second".into(),
ItemType::Task,
Priority::Low,
vec![],
);
save_item(dir.path(), &item2).unwrap();
let item1 = Item::new(
"JOY-0001".into(),
"First".into(),
ItemType::Task,
Priority::Low,
vec![],
);
save_item(dir.path(), &item1).unwrap();
let items = load_items(dir.path()).unwrap();
assert_eq!(items[0].id, "JOY-0001");
assert_eq!(items[1].id, "JOY-0002");
}
#[test]
fn short_form_extracts_prefix_for_suffixed_id() {
assert_eq!(short_form("JOY-0042-A3"), Some("JOY-0042"));
assert_eq!(short_form("TST-00FF-12"), Some("TST-00FF"));
}
#[test]
fn short_form_returns_none_for_legacy_id() {
assert_eq!(short_form("JOY-0042"), None);
assert_eq!(short_form("JOY-MS-01"), None);
}
#[test]
fn short_form_returns_none_for_non_hex_suffix() {
assert_eq!(short_form("JOY-0042-XX"), None);
assert_eq!(short_form("JOY-0042-AAA"), None);
}
#[test]
fn normalize_rewrites_short_form_parent() {
let mut parent = Item::new(
"JOY-0042-A3".into(),
"P".into(),
ItemType::Epic,
Priority::Medium,
vec![],
);
parent.parent = None;
let mut child = Item::new(
"JOY-0043-B1".into(),
"C".into(),
ItemType::Task,
Priority::Medium,
vec![],
);
child.parent = Some("JOY-0042".into());
let mut items = vec![parent, child];
normalize_id_refs(&mut items);
assert_eq!(items[1].parent.as_deref(), Some("JOY-0042-A3"));
}
#[test]
fn normalize_rewrites_short_form_deps() {
let dep = Item::new(
"JOY-0042-A3".into(),
"D".into(),
ItemType::Task,
Priority::Medium,
vec![],
);
let mut consumer = Item::new(
"JOY-0043-B1".into(),
"C".into(),
ItemType::Task,
Priority::Medium,
vec![],
);
consumer.deps = vec!["JOY-0042".into()];
let mut items = vec![dep, consumer];
normalize_id_refs(&mut items);
assert_eq!(items[1].deps, vec!["JOY-0042-A3".to_string()]);
}
#[test]
fn normalize_leaves_full_form_unchanged() {
let parent = Item::new(
"JOY-0042-A3".into(),
"P".into(),
ItemType::Epic,
Priority::Medium,
vec![],
);
let mut child = Item::new(
"JOY-0043-B1".into(),
"C".into(),
ItemType::Task,
Priority::Medium,
vec![],
);
child.parent = Some("JOY-0042-A3".into());
let mut items = vec![parent, child];
normalize_id_refs(&mut items);
assert_eq!(items[1].parent.as_deref(), Some("JOY-0042-A3"));
}
#[test]
fn normalize_leaves_unknown_refs_unchanged() {
let mut child = Item::new(
"JOY-0043-B1".into(),
"C".into(),
ItemType::Task,
Priority::Medium,
vec![],
);
child.parent = Some("JOY-9999".into());
child.deps = vec!["JOY-8888".into()];
let mut items = vec![child];
normalize_id_refs(&mut items);
assert_eq!(items[0].parent.as_deref(), Some("JOY-9999"));
assert_eq!(items[0].deps, vec!["JOY-8888".to_string()]);
}
#[test]
fn normalize_leaves_ambiguous_short_forms_unchanged() {
let a = Item::new(
"JOY-0042-A3".into(),
"A".into(),
ItemType::Task,
Priority::Medium,
vec![],
);
let b = Item::new(
"JOY-0042-B1".into(),
"B".into(),
ItemType::Task,
Priority::Medium,
vec![],
);
let mut child = Item::new(
"JOY-0043-CC".into(),
"C".into(),
ItemType::Task,
Priority::Medium,
vec![],
);
child.parent = Some("JOY-0042".into());
let mut items = vec![a, b, child];
normalize_id_refs(&mut items);
assert_eq!(items[2].parent.as_deref(), Some("JOY-0042"));
}
#[test]
fn milestone_short_form_extracts_prefix() {
assert_eq!(milestone_short_form("JOY-MS-01-A1"), Some("JOY-MS-01"));
assert_eq!(milestone_short_form("TST-MS-FF-12"), Some("TST-MS-FF"));
}
#[test]
fn milestone_short_form_returns_none_for_legacy_or_item() {
assert_eq!(milestone_short_form("JOY-MS-01"), None);
assert_eq!(milestone_short_form("JOY-0042-A3"), None);
}
#[test]
fn normalize_milestone_rewrites_short_form() {
let mut item = Item::new(
"JOY-0001-AA".into(),
"X".into(),
ItemType::Task,
Priority::Medium,
vec![],
);
item.milestone = Some("JOY-MS-01".into());
let mut items = vec![item];
normalize_milestone_refs(&mut items, &["JOY-MS-01-A1".to_string()]);
assert_eq!(items[0].milestone.as_deref(), Some("JOY-MS-01-A1"));
}
#[test]
fn normalize_milestone_leaves_unknown_unchanged() {
let mut item = Item::new(
"JOY-0001-AA".into(),
"X".into(),
ItemType::Task,
Priority::Medium,
vec![],
);
item.milestone = Some("JOY-MS-99".into());
let mut items = vec![item];
normalize_milestone_refs(&mut items, &["JOY-MS-01-A1".to_string()]);
assert_eq!(items[0].milestone.as_deref(), Some("JOY-MS-99"));
}
#[test]
fn normalize_milestone_leaves_full_form_unchanged() {
let mut item = Item::new(
"JOY-0001-AA".into(),
"X".into(),
ItemType::Task,
Priority::Medium,
vec![],
);
item.milestone = Some("JOY-MS-01-A1".into());
let mut items = vec![item];
normalize_milestone_refs(&mut items, &["JOY-MS-01-A1".to_string()]);
assert_eq!(items[0].milestone.as_deref(), Some("JOY-MS-01-A1"));
}
#[test]
fn normalize_handles_legacy_parent_referenced_by_full_id() {
let parent = Item::new(
"JOY-0042".into(),
"P".into(),
ItemType::Epic,
Priority::Medium,
vec![],
);
let mut child = Item::new(
"JOY-0043-B1".into(),
"C".into(),
ItemType::Task,
Priority::Medium,
vec![],
);
child.parent = Some("JOY-0042".into());
let mut items = vec![parent, child];
normalize_id_refs(&mut items);
assert_eq!(items[1].parent.as_deref(), Some("JOY-0042"));
}
}