use crate::config::{IdStrategy, TypeConfig};
use crate::error::StoreError;
use crate::filters::Filters;
use crate::frontmatter::{extract_frontmatter_comment, generate_frontmatter, parse_frontmatter};
use crate::reconcile::{acquire_display_number_lock, get_next_display_number};
use crate::types::{
CreateOptions, DuplicateOptions, DuplicateResult, Frontmatter, Item, MoveResult, UpdateOptions,
};
use crate::util::now_iso;
use std::path::Path;
use tokio::fs;
fn is_item_file(name: &str) -> bool {
std::path::Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
}
fn validate_status(config: &TypeConfig, status: &str) -> Result<(), StoreError> {
if !config.features.status {
return Ok(());
}
if config.statuses.is_empty() {
return Ok(());
}
if config
.statuses
.iter()
.any(|s| s.eq_ignore_ascii_case(status))
{
Ok(())
} else {
Err(StoreError::InvalidStatus {
status: status.to_string(),
allowed: config.statuses.clone(),
})
}
}
fn validate_priority(config: &TypeConfig, priority: u32) -> Result<(), StoreError> {
if !config.features.priority {
return Ok(());
}
let max = config.priority_levels.unwrap_or(3);
if priority < 1 || priority > max {
return Err(StoreError::InvalidPriority { priority, max });
}
Ok(())
}
pub async fn create(
type_dir: &Path,
config: &TypeConfig,
options: CreateOptions,
) -> Result<Item, StoreError> {
fs::create_dir_all(type_dir).await?;
let id = match &options.id {
Some(explicit_id) => explicit_id.clone(),
None => {
if config.identifier == IdStrategy::Slug {
let slug = slug::slugify(&options.title);
if slug.is_empty() {
return Err(StoreError::ValidationError(
"Cannot generate slug from empty title".to_string(),
));
}
slug
} else {
uuid::Uuid::new_v4().to_string()
}
}
};
let file_path = type_dir.join(format!("{id}.md"));
if file_path.exists() {
return Err(StoreError::AlreadyExists(id));
}
let _dn_guard;
let display_number = if config.features.display_number {
_dn_guard = Some(acquire_display_number_lock(type_dir).await);
Some(get_next_display_number(type_dir).await?)
} else {
_dn_guard = None;
None
};
let status = if config.features.status {
let s = options
.status
.or_else(|| config.default_status.clone())
.unwrap_or_default();
validate_status(config, &s)?;
Some(s)
} else {
None
};
let priority = if config.features.priority {
let max = config.priority_levels.unwrap_or(3);
let p = options
.priority
.unwrap_or_else(|| crate::validation::priority::default_priority(max));
validate_priority(config, p)?;
Some(p)
} else {
None
};
let now = now_iso();
let frontmatter = Frontmatter {
display_number,
status,
priority,
created_at: now.clone(),
updated_at: now,
deleted_at: None,
tags: options.tags,
projects: options.projects,
custom_fields: options.custom_fields,
};
let comment = options.comment.clone();
let content = generate_frontmatter(
&frontmatter,
&options.title,
&options.body,
comment.as_deref(),
);
fs::write(&file_path, &content).await?;
Ok(Item {
id,
title: options.title,
body: options.body,
frontmatter,
comment,
})
}
pub async fn get(type_dir: &Path, id: &str) -> Result<Item, StoreError> {
let file_path = type_dir.join(format!("{id}.md"));
if !file_path.exists() {
return Err(StoreError::NotFound(id.to_string()));
}
let content = fs::read_to_string(&file_path).await?;
let comment = extract_frontmatter_comment(&content);
let (frontmatter, title, body) = parse_frontmatter::<Frontmatter>(&content)
.map_err(|e| StoreError::Custom(format!("Frontmatter error: {e}")))?;
Ok(Item {
id: id.to_string(),
title,
body,
frontmatter,
comment,
})
}
pub async fn list(type_dir: &Path, filters: Filters) -> Result<Vec<Item>, StoreError> {
if !type_dir.exists() {
return Ok(Vec::new());
}
let mut items = Vec::new();
let mut entries = fs::read_dir(type_dir).await?;
while let Some(entry) = entries.next_entry().await? {
if !entry.file_type().await?.is_file() {
continue;
}
let name = match entry.file_name().to_str() {
Some(n) => n.to_string(),
None => continue,
};
if !is_item_file(&name) {
continue;
}
let id = name.trim_end_matches(".md").to_string();
let content = match fs::read_to_string(entry.path()).await {
Ok(c) => c,
Err(e) => {
tracing::warn!(path = %entry.path().display(), error = %e, "skipping file: read error");
continue;
}
};
let comment = extract_frontmatter_comment(&content);
let (frontmatter, title, body) = match parse_frontmatter::<Frontmatter>(&content) {
Ok(result) => result,
Err(e) => {
tracing::warn!(path = %entry.path().display(), error = %e, "skipping file: malformed frontmatter");
continue;
}
};
items.push(Item {
id,
title,
body,
frontmatter,
comment,
});
}
let items = apply_filters(items, &filters);
Ok(items)
}
fn apply_filters(mut items: Vec<Item>, filters: &Filters) -> Vec<Item> {
if !filters.include_deleted {
items.retain(|item| item.frontmatter.deleted_at.is_none());
}
if let Some(ref status_filters) = filters.statuses {
items.retain(|item| {
item.frontmatter
.status
.as_ref()
.is_some_and(|s| status_filters.iter().any(|f| f.eq_ignore_ascii_case(s)))
});
}
if let Some(priority_filter) = filters.priority {
items.retain(|item| item.frontmatter.priority == Some(priority_filter));
}
if let Some(lte) = filters.priority_lte {
items.retain(|item| item.frontmatter.priority.is_some_and(|p| p <= lte));
}
if let Some(gte) = filters.priority_gte {
items.retain(|item| item.frontmatter.priority.is_some_and(|p| p >= gte));
}
if let Some(ref any_tags) = filters.tags_any {
items.retain(|item| {
let item_tags = item.frontmatter.tags.as_deref().unwrap_or(&[]);
any_tags.iter().any(|t| item_tags.contains(t))
});
}
if let Some(ref all_tags) = filters.tags_all {
items.retain(|item| {
let item_tags = item.frontmatter.tags.as_deref().unwrap_or(&[]);
all_tags.iter().all(|t| item_tags.contains(t))
});
}
items.sort_by(
|a, b| match (a.frontmatter.display_number, b.frontmatter.display_number) {
(Some(an), Some(bn)) => an.cmp(&bn),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.frontmatter.created_at.cmp(&b.frontmatter.created_at),
},
);
if let Some(offset) = filters.offset {
if offset < items.len() {
items = items.split_off(offset);
} else {
items.clear();
}
}
if let Some(limit) = filters.limit {
items.truncate(limit);
}
items
}
pub async fn update(
type_dir: &Path,
config: &TypeConfig,
id: &str,
options: UpdateOptions,
) -> Result<Item, StoreError> {
let file_path = type_dir.join(format!("{id}.md"));
if !file_path.exists() {
return Err(StoreError::NotFound(id.to_string()));
}
let content = fs::read_to_string(&file_path).await?;
let existing_comment = extract_frontmatter_comment(&content);
let (mut frontmatter, current_title, current_body) = parse_frontmatter::<Frontmatter>(&content)
.map_err(|e| StoreError::Custom(format!("Frontmatter error: {e}")))?;
if frontmatter.deleted_at.is_some() {
return Err(StoreError::IsDeleted(id.to_string()));
}
if let Some(ref new_status) = options.status {
validate_status(config, new_status)?;
frontmatter.status = Some(new_status.clone());
}
if let Some(new_priority) = options.priority {
validate_priority(config, new_priority)?;
frontmatter.priority = Some(new_priority);
}
if let Some(new_tags) = options.tags {
frontmatter.tags = if new_tags.is_empty() {
None
} else {
Some(new_tags)
};
}
if let Some(new_projects) = options.projects {
frontmatter.projects = if new_projects.is_empty() {
None
} else {
Some(new_projects)
};
}
for (key, value) in &options.custom_fields {
frontmatter.custom_fields.insert(key.clone(), value.clone());
}
frontmatter.updated_at = now_iso();
let title = options.title.unwrap_or(current_title);
let body = options.body.unwrap_or(current_body);
let comment = match options.comment {
Some(c) if c.is_empty() => None,
Some(c) => Some(c),
None => existing_comment,
};
let new_content = generate_frontmatter(&frontmatter, &title, &body, comment.as_deref());
fs::write(&file_path, &new_content).await?;
Ok(Item {
id: id.to_string(),
title,
body,
frontmatter,
comment,
})
}
pub async fn delete(type_dir: &Path, id: &str, force: bool) -> Result<(), StoreError> {
let file_path = type_dir.join(format!("{id}.md"));
if !file_path.exists() {
return Err(StoreError::NotFound(id.to_string()));
}
if !force {
let content = fs::read_to_string(&file_path).await?;
let (frontmatter, _, _) = parse_frontmatter::<Frontmatter>(&content)
.map_err(|e| StoreError::Custom(format!("Frontmatter error: {e}")))?;
if frontmatter.deleted_at.is_none() {
return soft_delete(type_dir, id).await;
}
}
fs::remove_file(&file_path).await?;
Ok(())
}
pub async fn soft_delete(type_dir: &Path, id: &str) -> Result<(), StoreError> {
let file_path = type_dir.join(format!("{id}.md"));
if !file_path.exists() {
return Err(StoreError::NotFound(id.to_string()));
}
let content = fs::read_to_string(&file_path).await?;
let comment = extract_frontmatter_comment(&content);
let (mut frontmatter, title, body) = parse_frontmatter::<Frontmatter>(&content)
.map_err(|e| StoreError::Custom(format!("Frontmatter error: {e}")))?;
if frontmatter.deleted_at.is_some() {
return Err(StoreError::IsDeleted(id.to_string()));
}
let now = now_iso();
frontmatter.deleted_at = Some(now.clone());
frontmatter.updated_at = now;
let new_content = generate_frontmatter(&frontmatter, &title, &body, comment.as_deref());
fs::write(&file_path, &new_content).await?;
Ok(())
}
pub async fn restore(type_dir: &Path, id: &str) -> Result<(), StoreError> {
let file_path = type_dir.join(format!("{id}.md"));
if !file_path.exists() {
return Err(StoreError::NotFound(id.to_string()));
}
let content = fs::read_to_string(&file_path).await?;
let comment = extract_frontmatter_comment(&content);
let (mut frontmatter, title, body) = parse_frontmatter::<Frontmatter>(&content)
.map_err(|e| StoreError::Custom(format!("Frontmatter error: {e}")))?;
if frontmatter.deleted_at.is_none() {
return Err(StoreError::Custom(format!("Item '{id}' is not deleted")));
}
frontmatter.deleted_at = None;
frontmatter.updated_at = now_iso();
let new_content = generate_frontmatter(&frontmatter, &title, &body, comment.as_deref());
fs::write(&file_path, &new_content).await?;
Ok(())
}
pub async fn duplicate(
config: &TypeConfig,
options: DuplicateOptions,
) -> Result<DuplicateResult, StoreError> {
if !config.features.duplicate {
return Err(StoreError::FeatureNotEnabled(format!(
"duplicate is not enabled for {}",
config.name
)));
}
let source_item = get(&options.source_dir, &options.item_id).await?;
let new_id = match options.new_id {
Some(ref id) if !id.is_empty() => {
if config.identifier == IdStrategy::Slug {
slug::slugify(id)
} else {
id.clone()
}
}
_ => {
if config.identifier == IdStrategy::Slug {
format!("{}-copy", options.item_id)
} else {
uuid::Uuid::new_v4().to_string()
}
}
};
fs::create_dir_all(&options.target_dir).await?;
let target_file = options.target_dir.join(format!("{new_id}.md"));
if target_file.exists() {
return Err(StoreError::AlreadyExists(new_id));
}
if let Some(ref status) = source_item.frontmatter.status {
validate_status(config, status)?;
}
if let Some(priority) = source_item.frontmatter.priority {
validate_priority(config, priority)?;
}
let _dn_guard;
let display_number = if config.features.display_number {
_dn_guard = Some(acquire_display_number_lock(&options.target_dir).await);
Some(get_next_display_number(&options.target_dir).await?)
} else {
_dn_guard = None;
None
};
let new_title = options
.new_title
.unwrap_or_else(|| format!("Copy of {}", source_item.title));
let now = now_iso();
let frontmatter = Frontmatter {
display_number,
status: source_item.frontmatter.status.clone(),
priority: source_item.frontmatter.priority,
created_at: now.clone(),
updated_at: now,
deleted_at: None,
tags: source_item.frontmatter.tags.clone(),
projects: source_item.frontmatter.projects.clone(),
custom_fields: source_item.frontmatter.custom_fields.clone(),
};
let comment = source_item.comment.clone();
let content = generate_frontmatter(
&frontmatter,
&new_title,
&source_item.body,
comment.as_deref(),
);
fs::write(&target_file, &content).await?;
Ok(DuplicateResult {
item: Item {
id: new_id,
title: new_title,
body: source_item.body,
frontmatter,
comment,
},
original_id: options.item_id,
})
}
pub async fn move_item(
source_dir: &Path,
target_dir: &Path,
source_config: &TypeConfig,
target_config: &TypeConfig,
item_id: &str,
new_id: Option<&str>,
) -> Result<MoveResult, StoreError> {
if !source_config.features.move_item {
return Err(StoreError::FeatureNotEnabled(format!(
"move is not enabled for {}",
source_config.name
)));
}
if !target_config.features.move_item {
return Err(StoreError::FeatureNotEnabled(format!(
"move is not enabled for {}",
target_config.name
)));
}
let source_canonical =
std::fs::canonicalize(source_dir).unwrap_or_else(|_| source_dir.to_path_buf());
let target_canonical =
std::fs::canonicalize(target_dir).unwrap_or_else(|_| target_dir.to_path_buf());
if source_canonical == target_canonical {
return Err(StoreError::SameLocation);
}
let source_file = source_dir.join(format!("{item_id}.md"));
if !source_file.exists() {
return Err(StoreError::NotFound(item_id.to_string()));
}
let content = fs::read_to_string(&source_file).await?;
let comment = extract_frontmatter_comment(&content);
let (mut frontmatter, title, body) = parse_frontmatter::<Frontmatter>(&content)
.map_err(|e| StoreError::FrontmatterError(e.to_string()))?;
if target_config.features.status {
if let Some(ref status) = frontmatter.status {
validate_status(target_config, status)?;
}
}
if target_config.features.priority {
if let Some(priority) = frontmatter.priority {
validate_priority(target_config, priority)?;
}
}
let target_id = if source_config.identifier == IdStrategy::Slug {
new_id.unwrap_or(item_id).to_string()
} else {
item_id.to_string()
};
fs::create_dir_all(target_dir).await?;
let target_file = target_dir.join(format!("{target_id}.md"));
if target_file.exists() {
return Err(StoreError::AlreadyExists(target_id));
}
let _dn_guard = if target_config.features.display_number {
let guard = acquire_display_number_lock(target_dir).await;
frontmatter.display_number = Some(get_next_display_number(target_dir).await?);
Some(guard)
} else {
None
};
frontmatter.updated_at = now_iso();
let new_content = generate_frontmatter(&frontmatter, &title, &body, comment.as_deref());
fs::write(&target_file, &new_content).await?;
fs::remove_file(&source_file).await?;
Ok(MoveResult {
item: Item {
id: target_id,
title,
body,
frontmatter,
comment,
},
old_id: item_id.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::TypeFeatures;
use std::collections::HashMap;
fn issue_config() -> TypeConfig {
TypeConfig {
name: "Issue".to_string(),
identifier: IdStrategy::Uuid,
features: TypeFeatures {
display_number: true,
status: true,
priority: true,
assets: false,
org_sync: false,
move_item: true,
duplicate: true,
},
statuses: vec![
"open".to_string(),
"planning".to_string(),
"in-progress".to_string(),
"closed".to_string(),
],
default_status: Some("open".to_string()),
priority_levels: Some(3),
custom_fields: Vec::new(),
}
}
fn minimal_config() -> TypeConfig {
TypeConfig {
name: "Note".to_string(),
identifier: IdStrategy::Uuid,
features: TypeFeatures::default(),
statuses: Vec::new(),
default_status: None,
priority_levels: None,
custom_fields: Vec::new(),
}
}
#[tokio::test]
async fn test_create_and_get() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("issues");
let config = issue_config();
let options = CreateOptions {
title: "Test Issue".to_string(),
body: "This is a test.".to_string(),
id: None,
status: Some("open".to_string()),
priority: Some(2),
tags: None,
projects: None,
custom_fields: HashMap::new(),
comment: None,
};
let created = create(&type_dir, &config, options).await.unwrap();
assert_eq!(created.title, "Test Issue");
assert_eq!(created.body, "This is a test.");
assert_eq!(created.frontmatter.display_number, Some(1));
assert_eq!(created.frontmatter.status, Some("open".to_string()));
assert_eq!(created.frontmatter.priority, Some(2));
let fetched = get(&type_dir, &created.id).await.unwrap();
assert_eq!(fetched.title, "Test Issue");
assert_eq!(fetched.frontmatter.display_number, Some(1));
}
#[tokio::test]
async fn test_create_minimal_features() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("notes");
let config = minimal_config();
let options = CreateOptions {
title: "Simple Note".to_string(),
body: "Just a note.".to_string(),
id: None,
status: None,
priority: None,
tags: None,
projects: None,
custom_fields: HashMap::new(),
comment: None,
};
let created = create(&type_dir, &config, options).await.unwrap();
assert!(created.frontmatter.display_number.is_none());
assert!(created.frontmatter.status.is_none());
assert!(created.frontmatter.priority.is_none());
}
#[tokio::test]
async fn test_create_slug_id_strategy() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("docs");
let mut config = minimal_config();
config.identifier = IdStrategy::Slug;
let options = CreateOptions {
title: "Getting Started Guide".to_string(),
body: "Welcome!".to_string(),
id: None,
status: None,
priority: None,
tags: None,
projects: None,
custom_fields: HashMap::new(),
comment: None,
};
let created = create(&type_dir, &config, options).await.unwrap();
assert_eq!(created.id, "getting-started-guide");
}
#[tokio::test]
async fn test_create_invalid_status() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("issues");
let config = issue_config();
let options = CreateOptions {
title: "Bad Status".to_string(),
body: String::new(),
id: None,
status: Some("nonexistent".to_string()),
priority: None,
tags: None,
projects: None,
custom_fields: HashMap::new(),
comment: None,
};
let result = create(&type_dir, &config, options).await;
assert!(result.is_err());
assert!(matches!(result, Err(StoreError::InvalidStatus { .. })));
}
#[tokio::test]
async fn test_create_invalid_priority() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("issues");
let config = issue_config();
let options = CreateOptions {
title: "Bad Priority".to_string(),
body: String::new(),
id: None,
status: Some("open".to_string()),
priority: Some(99),
tags: None,
projects: None,
custom_fields: HashMap::new(),
comment: None,
};
let result = create(&type_dir, &config, options).await;
assert!(result.is_err());
assert!(matches!(result, Err(StoreError::InvalidPriority { .. })));
}
#[tokio::test]
async fn test_list_with_filters() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("issues");
let config = issue_config();
for (title, status) in [
("Open 1", "open"),
("Open 2", "open"),
("Closed 1", "closed"),
] {
let options = CreateOptions {
title: title.to_string(),
body: String::new(),
id: None,
status: Some(status.to_string()),
priority: Some(2),
tags: None,
projects: None,
custom_fields: HashMap::new(),
comment: None,
};
create(&type_dir, &config, options).await.unwrap();
}
let all = list(&type_dir, Filters::default()).await.unwrap();
assert_eq!(all.len(), 3);
let open = list(&type_dir, Filters::new().with_status("open"))
.await
.unwrap();
assert_eq!(open.len(), 2);
let limited = list(&type_dir, Filters::new().with_limit(1)).await.unwrap();
assert_eq!(limited.len(), 1);
let offset = list(&type_dir, Filters::new().with_offset(2))
.await
.unwrap();
assert_eq!(offset.len(), 1);
}
#[tokio::test]
async fn test_update() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("issues");
let config = issue_config();
let options = CreateOptions {
title: "Original Title".to_string(),
body: "Original body.".to_string(),
id: None,
status: Some("open".to_string()),
priority: Some(2),
tags: None,
projects: None,
custom_fields: HashMap::new(),
comment: None,
};
let created = create(&type_dir, &config, options).await.unwrap();
let update_options = UpdateOptions {
title: Some("Updated Title".to_string()),
body: Some("Updated body.".to_string()),
status: Some("closed".to_string()),
priority: Some(1),
tags: None,
projects: None,
custom_fields: HashMap::from([("env".to_string(), serde_json::json!("prod"))]),
comment: None,
};
let updated = update(&type_dir, &config, &created.id, update_options)
.await
.unwrap();
assert_eq!(updated.title, "Updated Title");
assert_eq!(updated.body, "Updated body.");
assert_eq!(updated.frontmatter.status, Some("closed".to_string()));
assert_eq!(updated.frontmatter.priority, Some(1));
assert_eq!(
updated.frontmatter.custom_fields.get("env"),
Some(&serde_json::json!("prod"))
);
}
#[tokio::test]
async fn test_update_not_found() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("issues");
fs::create_dir_all(&type_dir).await.unwrap();
let config = issue_config();
let result = update(&type_dir, &config, "nonexistent", UpdateOptions::default()).await;
assert!(result.is_err());
assert!(matches!(result, Err(StoreError::NotFound(_))));
}
#[tokio::test]
async fn test_soft_delete_and_restore() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("issues");
let config = issue_config();
let options = CreateOptions {
title: "To Delete".to_string(),
body: String::new(),
id: None,
status: Some("open".to_string()),
priority: Some(2),
tags: None,
projects: None,
custom_fields: HashMap::new(),
comment: None,
};
let created = create(&type_dir, &config, options).await.unwrap();
soft_delete(&type_dir, &created.id).await.unwrap();
let items = list(&type_dir, Filters::default()).await.unwrap();
assert!(items.is_empty());
let items = list(&type_dir, Filters::new().include_deleted())
.await
.unwrap();
assert_eq!(items.len(), 1);
assert!(items.first().unwrap().frontmatter.deleted_at.is_some());
restore(&type_dir, &created.id).await.unwrap();
let items = list(&type_dir, Filters::default()).await.unwrap();
assert_eq!(items.len(), 1);
assert!(items.first().unwrap().frontmatter.deleted_at.is_none());
}
#[tokio::test]
async fn test_soft_delete_timestamps_are_identical() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("issues");
let config = issue_config();
let options = CreateOptions {
title: "Timestamp Test".to_string(),
body: String::new(),
id: None,
status: Some("open".to_string()),
priority: Some(2),
tags: None,
projects: None,
custom_fields: HashMap::new(),
comment: None,
};
let created = create(&type_dir, &config, options).await.unwrap();
soft_delete(&type_dir, &created.id).await.unwrap();
let items = list(&type_dir, Filters::new().include_deleted())
.await
.unwrap();
let item = items.first().unwrap();
assert_eq!(
item.frontmatter.deleted_at,
Some(item.frontmatter.updated_at.clone()),
"deleted_at and updated_at must be identical after soft_delete"
);
}
#[tokio::test]
async fn test_hard_delete() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("issues");
let config = issue_config();
let options = CreateOptions {
title: "To Hard Delete".to_string(),
body: String::new(),
id: None,
status: Some("open".to_string()),
priority: Some(2),
tags: None,
projects: None,
custom_fields: HashMap::new(),
comment: None,
};
let created = create(&type_dir, &config, options).await.unwrap();
delete(&type_dir, &created.id, true).await.unwrap();
let result = get(&type_dir, &created.id).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_display_number_auto_increment() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("issues");
let config = issue_config();
for i in 1..=3u32 {
let options = CreateOptions {
title: format!("Issue {i}"),
body: String::new(),
id: None,
status: Some("open".to_string()),
priority: Some(2),
tags: None,
projects: None,
custom_fields: HashMap::new(),
comment: None,
};
let created = create(&type_dir, &config, options).await.unwrap();
assert_eq!(created.frontmatter.display_number, Some(i));
}
}
#[tokio::test]
async fn test_concurrent_create_unique_display_numbers() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("issues");
let config = std::sync::Arc::new(issue_config());
let handles: Vec<_> = (0..10)
.map(|i| {
let dir = type_dir.clone();
let cfg = std::sync::Arc::clone(&config);
tokio::spawn(async move {
let options = CreateOptions {
title: format!("Concurrent Issue {i}"),
body: String::new(),
id: None,
status: Some("open".to_string()),
priority: Some(2),
tags: None,
projects: None,
custom_fields: HashMap::new(),
comment: None,
};
create(&dir, &cfg, options).await.unwrap()
})
})
.collect();
let mut display_numbers: Vec<u32> = Vec::new();
for handle in handles {
let item = handle.await.unwrap();
display_numbers.push(item.frontmatter.display_number.unwrap());
}
display_numbers.sort_unstable();
assert_eq!(display_numbers, (1..=10).collect::<Vec<u32>>());
}
#[tokio::test]
async fn test_update_preserves_fields() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("issues");
let config = issue_config();
let options = CreateOptions {
title: "Keep Fields".to_string(),
body: "Original body.".to_string(),
id: None,
status: Some("open".to_string()),
priority: Some(1),
tags: None,
projects: None,
custom_fields: HashMap::from([("key".to_string(), serde_json::json!("value"))]),
comment: None,
};
let created = create(&type_dir, &config, options).await.unwrap();
let updated = update(
&type_dir,
&config,
&created.id,
UpdateOptions {
title: Some("New Title".to_string()),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.title, "New Title");
assert_eq!(updated.body, "Original body.");
assert_eq!(updated.frontmatter.status, Some("open".to_string()));
assert_eq!(updated.frontmatter.priority, Some(1));
assert_eq!(
updated.frontmatter.custom_fields.get("key"),
Some(&serde_json::json!("value"))
);
}
#[tokio::test]
async fn test_cannot_update_deleted_item() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("issues");
let config = issue_config();
let options = CreateOptions {
title: "Will Delete".to_string(),
body: String::new(),
id: None,
status: Some("open".to_string()),
priority: Some(2),
tags: None,
projects: None,
custom_fields: HashMap::new(),
comment: None,
};
let created = create(&type_dir, &config, options).await.unwrap();
soft_delete(&type_dir, &created.id).await.unwrap();
let result = update(
&type_dir,
&config,
&created.id,
UpdateOptions {
title: Some("Fail".to_string()),
..Default::default()
},
)
.await;
assert!(result.is_err());
assert!(matches!(result, Err(StoreError::IsDeleted(_))));
}
#[tokio::test]
async fn test_already_exists() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("notes");
let mut config = minimal_config();
config.identifier = IdStrategy::Slug;
let options = CreateOptions {
title: "Same Title".to_string(),
body: String::new(),
id: None,
status: None,
priority: None,
tags: None,
projects: None,
custom_fields: HashMap::new(),
comment: None,
};
create(&type_dir, &config, options.clone()).await.unwrap();
let result = create(&type_dir, &config, options).await;
assert!(result.is_err());
assert!(matches!(result, Err(StoreError::AlreadyExists(_))));
}
#[tokio::test]
async fn test_get_not_found() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("issues");
fs::create_dir_all(&type_dir).await.unwrap();
let result = get(&type_dir, "nonexistent").await;
assert!(result.is_err());
assert!(matches!(result, Err(StoreError::NotFound(_))));
}
#[tokio::test]
async fn test_list_empty() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("issues");
let items = list(&type_dir, Filters::default()).await.unwrap();
assert!(items.is_empty());
}
#[tokio::test]
async fn test_move_item_target_move_disabled_is_rejected() {
let temp = tempfile::tempdir().unwrap();
let source_dir = temp.path().join("issues");
let target_dir = temp.path().join("notes");
let source_config = issue_config(); let mut target_config = issue_config();
target_config.features.move_item = false;
let options = CreateOptions {
title: "To Move".to_string(),
body: String::new(),
id: None,
status: Some("open".to_string()),
priority: Some(1),
tags: None,
projects: None,
custom_fields: HashMap::new(),
comment: None,
};
let created = create(&source_dir, &source_config, options).await.unwrap();
let result = move_item(
&source_dir,
&target_dir,
&source_config,
&target_config,
&created.id,
None,
)
.await;
assert!(matches!(result, Err(StoreError::FeatureNotEnabled(_))));
}
#[tokio::test]
async fn test_duplicate_feature_disabled_returns_error() {
let temp = tempfile::tempdir().unwrap();
let type_dir = temp.path().join("issues");
let mut config = issue_config();
config.features.duplicate = false;
let options = CreateOptions {
title: "Original".to_string(),
body: String::new(),
id: None,
status: Some("open".to_string()),
priority: Some(2),
tags: None,
projects: None,
custom_fields: HashMap::new(),
comment: None,
};
let created = create(&type_dir, &config, options).await.unwrap();
let dup_options = DuplicateOptions {
source_dir: type_dir.clone(),
target_dir: type_dir.clone(),
item_id: created.id,
new_id: None,
new_title: None,
};
let result = duplicate(&config, dup_options).await;
assert!(result.is_err());
assert!(matches!(result, Err(StoreError::FeatureNotEnabled(_))));
}
#[tokio::test]
async fn test_move_item_preserves_custom_fields() {
let temp = tempfile::tempdir().unwrap();
let source_dir = temp.path().join("source");
let target_dir = temp.path().join("target");
let config = issue_config();
let options = CreateOptions {
title: "With Custom Fields".to_string(),
body: "body".to_string(),
id: None,
status: Some("open".to_string()),
priority: Some(1),
tags: None,
projects: None,
custom_fields: HashMap::from([
("draft".to_string(), serde_json::json!(true)),
("env".to_string(), serde_json::json!("staging")),
]),
comment: None,
};
let created = create(&source_dir, &config, options).await.unwrap();
let result = move_item(
&source_dir,
&target_dir,
&config,
&config,
&created.id,
None,
)
.await
.unwrap();
assert_eq!(
result.item.frontmatter.custom_fields.get("draft"),
Some(&serde_json::json!(true))
);
assert_eq!(
result.item.frontmatter.custom_fields.get("env"),
Some(&serde_json::json!("staging"))
);
assert!(!source_dir.join(format!("{}.md", created.id)).exists());
}
}