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);
write_item_file(&path, item)?;
let rel = format!("{}/{}/{}", store::JOY_DIR, store::ITEMS_DIR, filename);
crate::git_ops::auto_git_add(root, &[&rel]);
Ok(())
}
fn write_item_file(path: &Path, item: &Item) -> Result<(), JoyError> {
let yaml = serde_yaml_ng::to_string(item).map_err(JoyError::Yaml)?;
let bytes = match item.crypt_zone.as_deref() {
Some(zone) => {
let zone_key =
crate::crypt::active_zone_key(zone).ok_or_else(|| JoyError::ZoneAccessDenied {
zone: zone.to_string(),
})?;
crate::crypt::encrypt_blob(zone, &zone_key, yaml.as_bytes())
}
None => yaml.into_bytes(),
};
write_atomic(path, &bytes)
}
#[derive(Debug, Clone)]
pub struct ItemMeta {
pub id: String,
pub path: std::path::PathBuf,
pub encrypted_zone: Option<String>,
pub plaintext_crypt_zone: Option<String>,
}
impl ItemMeta {
pub fn zone(&self) -> Option<&str> {
self.encrypted_zone
.as_deref()
.or(self.plaintext_crypt_zone.as_deref())
}
}
pub fn list_item_metadata(root: &Path) -> Result<Vec<ItemMeta>, JoyError> {
let items_dir = store::joy_dir(root).join(store::ITEMS_DIR);
if !items_dir.is_dir() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in std::fs::read_dir(&items_dir).map_err(|e| JoyError::ReadFile {
path: items_dir.clone(),
source: e,
})? {
let Ok(entry) = entry else { continue };
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
let Some(id) = id_from_filename(name) else {
continue;
};
let bytes = std::fs::read(&path).map_err(|e| JoyError::ReadFile {
path: path.clone(),
source: e,
})?;
let (encrypted_zone, plaintext_crypt_zone) = if crate::crypt::looks_like_blob(&bytes) {
(parse_blob_zone(&bytes), None)
} else {
(None, parse_plaintext_crypt_zone(&bytes))
};
out.push(ItemMeta {
id,
path,
encrypted_zone,
plaintext_crypt_zone,
});
}
Ok(out)
}
fn id_from_filename(name: &str) -> Option<String> {
let stem = name.strip_suffix(".yaml")?;
let parts: Vec<&str> = stem.split('-').collect();
if parts.len() >= 2 && parts[1].chars().all(|c| c.is_ascii_hexdigit()) && parts[1].len() == 4 {
let id_end = if parts.len() >= 3
&& parts[2].chars().all(|c| c.is_ascii_hexdigit())
&& parts[2].len() == 2
{
3
} else {
2
};
Some(parts[..id_end].join("-"))
} else {
None
}
}
fn parse_blob_zone(bytes: &[u8]) -> Option<String> {
if bytes.len() < 10 {
return None;
}
let zone_len = bytes[9] as usize;
if bytes.len() < 10 + zone_len {
return None;
}
std::str::from_utf8(&bytes[10..10 + zone_len])
.ok()
.map(str::to_string)
}
fn parse_plaintext_crypt_zone(bytes: &[u8]) -> Option<String> {
let text = std::str::from_utf8(bytes).ok()?;
for line in text.lines() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("crypt_zone:") {
let value = rest.trim().trim_matches(|c: char| c == '"' || c == '\'');
if value.is_empty() || value == "null" || value == "~" {
return None;
}
return Some(value.to_string());
}
}
None
}
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), JoyError> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent).map_err(|e| JoyError::CreateDir {
path: parent.to_path_buf(),
source: e,
})?;
let tmp = parent.join(format!(
".{}.tmp.{}",
path.file_name().and_then(|s| s.to_str()).unwrap_or("item"),
std::process::id()
));
std::fs::write(&tmp, bytes).map_err(|e| JoyError::WriteFile {
path: tmp.clone(),
source: e,
})?;
std::fs::rename(&tmp, path).map_err(|e| JoyError::WriteFile {
path: path.to_path_buf(),
source: e,
})?;
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"));
}
}