use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::fs::parser::{
generate_task_md, generate_toml_frontmatter, parse_task_md, parse_toml_frontmatter_with_recovery,
};
use crate::models::task::TaskFrontmatter;
use crate::models::Task;
fn detect_frontmatter_format(dir: &Path) -> bool {
if let Ok(entries) = fs::read_dir(dir) {
let mut has_files = false;
let mut all_frontmatter = true;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("md") {
has_files = true;
if let Ok(content) = fs::read_to_string(&path) {
if !content.trim_start().starts_with("+++") {
all_frontmatter = false;
break;
}
}
}
}
!has_files || all_frontmatter
} else {
false
}
}
pub fn load_tasks_from_dir(dir: &Path, status: &str) -> Result<Vec<Task>, String> {
if !dir.exists() {
return Ok(Vec::new());
}
if detect_frontmatter_format(dir) {
return load_tasks_from_frontmatter(dir, status);
}
let mut tasks = Vec::new();
for entry in fs::read_dir(dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("md")
&& let Ok(task) = load_task(&path, status) {
tasks.push(task);
}
}
tasks.sort_by_key(|t| t.order);
Ok(tasks)
}
pub fn load_task(path: &Path, status: &str) -> Result<Task, String> {
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
let parsed = parse_task_md(&content)?;
let id = parsed
.metadata
.get("id")
.and_then(|s| s.parse::<u32>().ok())
.or_else(|| {
path.file_stem().and_then(|s| s.to_str()).and_then(|s| {
if let Ok(id) = s.parse::<u32>() {
return Some(id);
}
s.split('-')
.next()
.and_then(|prefix| prefix.parse::<u32>().ok())
})
})
.ok_or_else(|| {
format!(
"Task file missing 'id' field and filename is not numeric: {:?}",
path
)
})?;
let order = parsed
.metadata
.get("order")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or_else(|| (id as i32) * 1000);
let created = parsed
.metadata
.get("created")
.cloned()
.unwrap_or_else(|| "0".to_string());
let priority = parsed.metadata.get("priority").cloned();
let tags = parsed
.metadata
.get("tags")
.map(|s| {
s.split(',')
.map(|tag| tag.trim().to_string())
.filter(|tag| !tag.is_empty())
.collect()
})
.unwrap_or_else(Vec::new);
Ok(Task {
id,
order,
title: parsed.title,
content: parsed.content,
created,
priority,
status: status.to_string(),
tags,
file_path: path.to_path_buf(),
})
}
pub fn get_next_task_id(project_path: &Path) -> Result<u32, String> {
let mut max_id = 0;
let tasks_toml = project_path.join("tasks.toml");
if tasks_toml.exists() {
let metadata_map = load_tasks_metadata(project_path)?;
for (id_str, _) in metadata_map {
if let Ok(id) = id_str.parse::<u32>() {
if id > max_id {
max_id = id;
}
}
}
} else {
for entry in fs::read_dir(project_path).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.is_dir() && !path.file_name().unwrap().to_str().unwrap().starts_with('.') {
let status = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
if let Ok(tasks) = load_tasks_from_dir(&path, &status) {
for task in tasks {
if task.id > max_id {
max_id = task.id;
}
}
}
}
}
}
Ok(max_id + 1)
}
pub fn save_task(project_path: &Path, task: &Task) -> Result<PathBuf, String> {
let status_dir = project_path.join(&task.status);
if !status_dir.exists() {
fs::create_dir_all(&status_dir).map_err(|e| e.to_string())?;
}
let task_file = status_dir.join(format!("{}.md", task.id));
let is_new_task = !task_file.exists();
if detect_frontmatter_format(&status_dir) || is_new_project(project_path) || is_new_task {
save_task_frontmatter_format(project_path, task)
} else {
save_task_legacy_format(project_path, task)
}
}
fn is_new_project(project_path: &Path) -> bool {
let config_path = project_path.join(".kanban.toml");
if !config_path.exists() {
return true;
}
let config_content = match fs::read_to_string(&config_path) {
Ok(c) => c,
Err(_) => return true,
};
let project_config: crate::models::ProjectConfig = match toml::from_str(&config_content) {
Ok(c) => c,
Err(_) => return true,
};
for status in &project_config.statuses.order {
let status_dir = project_path.join(status);
if status_dir.exists() {
if let Ok(entries) = fs::read_dir(&status_dir) {
for entry in entries.flatten() {
if entry.path().extension().and_then(|s| s.to_str()) == Some("md") {
return false;
}
}
}
}
}
true
}
fn save_task_legacy_format(project_path: &Path, task: &Task) -> Result<PathBuf, String> {
let status_dir = project_path.join(&task.status);
let filename =
if task.file_path.exists() && task.file_path.parent() == Some(status_dir.as_path()) {
task.file_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| "Invalid file path".to_string())?
.to_string()
} else {
format!("{}.md", task.id)
};
let file_path = status_dir.join(&filename);
let mut metadata = HashMap::new();
metadata.insert("id".to_string(), task.id.to_string());
metadata.insert("order".to_string(), task.order.to_string());
metadata.insert("created".to_string(), task.created.clone());
if let Some(priority) = &task.priority {
metadata.insert("priority".to_string(), priority.clone());
}
if !task.tags.is_empty() {
metadata.insert("tags".to_string(), task.tags.join(", "));
}
let content = generate_task_md(&task.title, &metadata, &task.content);
if task.file_path.exists() && task.file_path != file_path {
let _ = fs::remove_file(&task.file_path);
}
fs::write(&file_path, content).map_err(|e| e.to_string())?;
Ok(file_path)
}
fn save_task_frontmatter_format(project_path: &Path, task: &Task) -> Result<PathBuf, String> {
let status_dir = project_path.join(&task.status);
let file_path = status_dir.join(format!("{}.md", task.id));
let frontmatter = TaskFrontmatter::from(task);
let content = generate_toml_frontmatter(&frontmatter, &task.title, &task.content);
if task.file_path.exists() && task.file_path != file_path {
let _ = fs::remove_file(&task.file_path);
}
fs::write(&file_path, content).map_err(|e| e.to_string())?;
Ok(file_path)
}
pub fn move_task(project_path: &Path, task: &Task, new_status: &str) -> Result<PathBuf, String> {
let old_path = &task.file_path;
let new_dir = project_path.join(new_status);
if !new_dir.exists() {
fs::create_dir_all(&new_dir).map_err(|e| e.to_string())?;
}
let filename = old_path
.file_name()
.ok_or_else(|| "Invalid file path".to_string())?;
let new_path = new_dir.join(filename);
fs::rename(old_path, &new_path).map_err(|e| e.to_string())?;
let tasks_toml = project_path.join("tasks.toml");
if tasks_toml.exists() {
let mut metadata_map = load_tasks_metadata(project_path)?;
if let Some(metadata) = metadata_map.get_mut(&task.id.to_string()) {
metadata.status = new_status.to_string();
save_tasks_metadata(project_path, &metadata_map)?;
}
}
Ok(new_path)
}
pub fn delete_task(project_path: &Path, task: &Task) -> Result<(), String> {
fs::remove_file(&task.file_path).map_err(|e| e.to_string())?;
let tasks_toml = project_path.join("tasks.toml");
if tasks_toml.exists() {
let mut metadata_map = load_tasks_metadata(project_path)?;
if metadata_map.remove(&task.id.to_string()).is_some() {
save_tasks_metadata(project_path, &metadata_map)?;
}
}
Ok(())
}
pub fn get_max_order_in_status(project_path: &Path, status: &str) -> Result<i32, String> {
let status_dir = project_path.join(status);
if !status_dir.exists() {
return Ok(-1000); }
let tasks = load_tasks_from_dir(&status_dir, status)?;
Ok(tasks.iter().map(|t| t.order).max().unwrap_or(-1000))
}
pub fn load_tasks_metadata(
project_path: &Path,
) -> Result<HashMap<String, crate::models::TaskMetadata>, String> {
let tasks_toml = project_path.join("tasks.toml");
if !tasks_toml.exists() {
return Ok(HashMap::new());
}
let content =
fs::read_to_string(&tasks_toml).map_err(|e| format!("Failed to read tasks.toml: {}", e))?;
let trimmed = content.trim();
if trimmed.is_empty() || trimmed == "[tasks]" {
return Ok(HashMap::new());
}
let config: crate::models::TasksConfig =
toml::from_str(&content).map_err(|e| format!("Failed to parse tasks.toml: {}", e))?;
Ok(config.tasks)
}
pub fn save_tasks_metadata(
project_path: &Path,
metadata: &HashMap<String, crate::models::TaskMetadata>,
) -> Result<(), String> {
let tasks_toml = project_path.join("tasks.toml");
let config = crate::models::TasksConfig {
tasks: metadata.clone(),
};
let content = toml::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize tasks.toml: {}", e))?;
fs::write(&tasks_toml, content).map_err(|e| format!("Failed to write tasks.toml: {}", e))?;
Ok(())
}
pub fn get_task_full_content(task: &Task) -> Result<String, String> {
fs::read_to_string(&task.file_path).map_err(|e| e.to_string())
}
fn load_tasks_from_frontmatter(dir: &Path, status: &str) -> Result<Vec<Task>, String> {
let mut tasks = Vec::new();
for entry in fs::read_dir(dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let parsed = match parse_toml_frontmatter_with_recovery(&content, &path) {
Ok(p) => p,
Err(e) => {
eprintln!("任务解析错误 {}: {}", path.display(), e);
continue;
}
};
tasks.push(Task {
id: parsed.frontmatter.id,
order: parsed.frontmatter.order,
title: parsed.title,
content: parsed.content,
created: parsed.frontmatter.created,
priority: parsed.frontmatter.priority,
status: status.to_string(),
tags: parsed.frontmatter.tags,
file_path: path,
});
}
tasks.sort_by_key(|t| t.order);
Ok(tasks)
}
pub fn migrate_metadata_to_frontmatter(project_path: &Path) -> Result<(), String> {
let tasks_toml = project_path.join("tasks.toml");
if !tasks_toml.exists() {
return Ok(());
}
let metadata_map = load_tasks_metadata(project_path)?;
if metadata_map.is_empty() {
let _ = fs::remove_file(&tasks_toml);
return Ok(());
}
for (id_str, metadata) in &metadata_map {
let content_path = project_path
.join(&metadata.status)
.join(format!("{}.md", id_str));
if !content_path.exists() {
continue;
}
let content = fs::read_to_string(&content_path)
.map_err(|e| format!("Failed to read content file: {}", e))?;
let frontmatter = TaskFrontmatter {
id: metadata.id,
order: metadata.order,
created: metadata.created.clone(),
priority: metadata.priority.clone(),
tags: metadata.tags.clone(),
};
let new_content = generate_toml_frontmatter(&frontmatter, &metadata.title, &content);
fs::write(&content_path, new_content)
.map_err(|e| format!("Failed to write frontmatter file: {}", e))?;
}
fs::remove_file(&tasks_toml).map_err(|e| format!("Failed to remove tasks.toml: {}", e))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup_legacy_project() -> TempDir {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path();
let config = r#"name = "Test Project"
created = "1234567890"
[statuses]
order = ["todo", "doing", "done"]
[statuses.todo]
display = "Todo"
[statuses.doing]
display = "Doing"
[statuses.done]
display = "Done"
"#;
fs::write(project_path.join(".kanban.toml"), config).unwrap();
fs::create_dir_all(project_path.join("todo")).unwrap();
fs::create_dir_all(project_path.join("doing")).unwrap();
fs::create_dir_all(project_path.join("done")).unwrap();
temp_dir
}
fn setup_metadata_project() -> TempDir {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path();
let config = r#"name = "Test Project"
created = "1234567890"
[statuses]
order = ["todo", "doing", "done"]
[statuses.todo]
display = "Todo"
[statuses.doing]
display = "Doing"
[statuses.done]
display = "Done"
"#;
fs::write(project_path.join(".kanban.toml"), config).unwrap();
fs::write(project_path.join("tasks.toml"), "[tasks]\n").unwrap();
fs::create_dir_all(project_path.join("todo")).unwrap();
fs::create_dir_all(project_path.join("doing")).unwrap();
fs::create_dir_all(project_path.join("done")).unwrap();
temp_dir
}
#[test]
fn test_load_task_legacy_format() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let task_content = r#"# Test Task
id: 1
order: 1000
created: 1234567890
priority: high
tags: bug, urgent
This is the task content.
"#;
fs::write(project_path.join("todo/1.md"), task_content).unwrap();
let task = load_task(&project_path.join("todo/1.md"), "todo").unwrap();
assert_eq!(task.id, 1);
assert_eq!(task.order, 1000);
assert_eq!(task.title, "Test Task");
assert_eq!(task.status, "todo");
assert_eq!(task.priority, Some("high".to_string()));
assert_eq!(task.tags, vec!["bug", "urgent"]);
assert!(task.content.contains("This is the task content."));
}
#[test]
fn test_load_task_from_filename() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let task_content = r#"# Task Without ID
order: 1000
created: 1234567890
Content here.
"#;
fs::write(project_path.join("todo/42.md"), task_content).unwrap();
let task = load_task(&project_path.join("todo/42.md"), "todo").unwrap();
assert_eq!(task.id, 42);
}
#[test]
fn test_load_tasks_from_dir_legacy() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let task1 = r#"# Task 1
id: 1
order: 2000
created: 1234567890
Content 1.
"#;
let task2 = r#"# Task 2
id: 2
order: 1000
created: 1234567891
Content 2.
"#;
fs::write(project_path.join("todo/1.md"), task1).unwrap();
fs::write(project_path.join("todo/2.md"), task2).unwrap();
let tasks = load_tasks_from_dir(&project_path.join("todo"), "todo").unwrap();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].id, 2);
assert_eq!(tasks[1].id, 1);
}
#[test]
fn test_load_tasks_from_dir_empty() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let tasks = load_tasks_from_dir(&project_path.join("todo"), "todo").unwrap();
assert!(tasks.is_empty());
}
#[test]
fn test_load_tasks_from_dir_nonexistent() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let tasks = load_tasks_from_dir(&project_path.join("nonexistent"), "nonexistent").unwrap();
assert!(tasks.is_empty());
}
#[test]
fn test_get_next_task_id() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let next_id = get_next_task_id(project_path).unwrap();
assert_eq!(next_id, 1);
let task = r#"# Task
id: 5
order: 1000
created: 1234567890
Content.
"#;
fs::write(project_path.join("todo/5.md"), task).unwrap();
let next_id = get_next_task_id(project_path).unwrap();
assert_eq!(next_id, 6);
}
#[test]
fn test_save_task_legacy_format() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let existing_task = r#"# Existing Task
id: 99
order: 99000
created: 1234567890
Existing content.
"#;
fs::write(project_path.join("todo/99.md"), existing_task).unwrap();
let task = Task {
id: 1,
order: 1000,
title: "New Task".to_string(),
content: "Task content here.".to_string(),
created: "1234567890".to_string(),
priority: Some("medium".to_string()),
status: "todo".to_string(),
tags: vec!["feature".to_string()],
file_path: PathBuf::new(),
};
let result = save_task(project_path, &task);
assert!(result.is_ok());
let saved_path = result.unwrap();
assert!(saved_path.exists());
assert_eq!(saved_path, project_path.join("todo/1.md"));
let content = fs::read_to_string(&saved_path).unwrap();
assert!(content.contains("# New Task"));
assert!(content.contains("id: 1"));
assert!(content.contains("priority: medium"));
assert!(content.contains("tags: feature"));
}
#[test]
fn test_save_task_metadata_format_migrates_to_frontmatter() {
let temp_dir = setup_metadata_project();
let project_path = temp_dir.path();
let task = Task {
id: 1,
order: 1000,
title: "New Task".to_string(),
content: "Task content here.".to_string(),
created: "1234567890".to_string(),
priority: Some("high".to_string()),
status: "todo".to_string(),
tags: vec!["bug".to_string(), "urgent".to_string()],
file_path: PathBuf::new(),
};
let result = save_task(project_path, &task);
assert!(result.is_ok());
assert!(!project_path.join("tasks.toml").exists());
let content_path = project_path.join("todo/1.md");
assert!(content_path.exists());
let content = fs::read_to_string(&content_path).unwrap();
assert!(content.starts_with("+++"));
assert!(content.contains("id = 1"));
assert!(content.contains("order = 1000"));
assert!(content.contains("priority = \"high\""));
assert!(content.contains("# New Task"));
assert!(content.contains("Task content here."));
}
#[test]
fn test_move_task_legacy_format() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let task_content = r#"# Task to Move
id: 1
order: 1000
created: 1234567890
Content.
"#;
fs::write(project_path.join("todo/1.md"), task_content).unwrap();
let task = load_task(&project_path.join("todo/1.md"), "todo").unwrap();
let result = move_task(project_path, &task, "doing");
assert!(result.is_ok());
assert!(!project_path.join("todo/1.md").exists());
assert!(project_path.join("doing/1.md").exists());
}
#[test]
fn test_move_task_metadata_format() {
let temp_dir = setup_metadata_project();
let project_path = temp_dir.path();
let task = Task {
id: 1,
order: 1000,
title: "Task to Move".to_string(),
content: "Content.".to_string(),
created: "1234567890".to_string(),
priority: None,
status: "todo".to_string(),
tags: vec![],
file_path: PathBuf::new(),
};
save_task(project_path, &task).unwrap();
let tasks = load_tasks_from_dir(&project_path.join("todo"), "todo").unwrap();
let task = &tasks[0];
let result = move_task(project_path, task, "done");
assert!(result.is_ok());
assert!(!project_path.join("todo/1.md").exists());
assert!(project_path.join("done/1.md").exists());
assert!(!project_path.join("tasks.toml").exists());
let content = fs::read_to_string(project_path.join("done/1.md")).unwrap();
assert!(content.starts_with("+++"));
assert!(content.contains("id = 1"));
assert!(content.contains("# Task to Move"));
}
#[test]
fn test_delete_task() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let task_content = "# Task\n\nid: 1\norder: 1000\ncreated: 0\n\nContent.";
fs::write(project_path.join("todo/1.md"), task_content).unwrap();
let task = load_task(&project_path.join("todo/1.md"), "todo").unwrap();
let result = delete_task(project_path, &task);
assert!(result.is_ok());
assert!(!project_path.join("todo/1.md").exists());
}
#[test]
fn test_delete_task_with_frontmatter() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let mut task = Task::new(1, "Test Task".to_string(), "todo".to_string());
task.order = 1000;
save_task(project_path, &task).unwrap();
assert!(project_path.join("todo/1.md").exists());
let tasks = load_tasks_from_dir(&project_path.join("todo"), "todo").unwrap();
let task = &tasks[0];
let result = delete_task(project_path, task);
assert!(result.is_ok());
assert!(!project_path.join("todo/1.md").exists());
}
#[test]
fn test_get_max_order_in_status() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let max_order = get_max_order_in_status(project_path, "todo").unwrap();
assert_eq!(max_order, -1000);
let task1 = "# Task 1\n\nid: 1\norder: 500\ncreated: 0\n\nContent.";
let task2 = "# Task 2\n\nid: 2\norder: 2000\ncreated: 0\n\nContent.";
fs::write(project_path.join("todo/1.md"), task1).unwrap();
fs::write(project_path.join("todo/2.md"), task2).unwrap();
let max_order = get_max_order_in_status(project_path, "todo").unwrap();
assert_eq!(max_order, 2000);
}
#[test]
fn test_load_tasks_from_frontmatter_multiple() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let task1 = Task {
id: 1,
order: 2000,
title: "Task 1".to_string(),
content: "Content 1.".to_string(),
created: "1234567890".to_string(),
priority: None,
status: "todo".to_string(),
tags: vec![],
file_path: PathBuf::new(),
};
let task2 = Task {
id: 2,
order: 1000,
title: "Task 2".to_string(),
content: "Content 2.".to_string(),
created: "1234567891".to_string(),
priority: Some("high".to_string()),
status: "todo".to_string(),
tags: vec!["urgent".to_string()],
file_path: PathBuf::new(),
};
let task3 = Task {
id: 3,
order: 3000,
title: "Task 3".to_string(),
content: "Content 3.".to_string(),
created: "1234567892".to_string(),
priority: None,
status: "done".to_string(),
tags: vec![],
file_path: PathBuf::new(),
};
save_task(project_path, &task1).unwrap();
save_task(project_path, &task2).unwrap();
save_task(project_path, &task3).unwrap();
let todo_tasks = load_tasks_from_dir(&project_path.join("todo"), "todo").unwrap();
assert_eq!(todo_tasks.len(), 2);
assert_eq!(todo_tasks[0].id, 2); assert_eq!(todo_tasks[1].id, 1);
let done_tasks = load_tasks_from_dir(&project_path.join("done"), "done").unwrap();
assert_eq!(done_tasks.len(), 1);
assert_eq!(done_tasks[0].id, 3);
}
#[test]
fn test_auto_migration() {
let test_dir = std::env::temp_dir().join("kanban_test_migration");
let _ = fs::remove_dir_all(&test_dir);
fs::create_dir_all(&test_dir).unwrap();
let config = r#"name = "Test Project"
created = "1234567890"
[statuses]
order = ["todo", "done"]
[statuses.todo]
display = "Todo"
[statuses.done]
display = "Done"
"#;
fs::write(test_dir.join(".kanban.toml"), config).unwrap();
fs::create_dir_all(test_dir.join("todo")).unwrap();
fs::create_dir_all(test_dir.join("done")).unwrap();
let task1 = r#"# Test Task 1
id: 1
order: 1000
created: 1234567890
priority: high
tags: test, feature
This is task content.
"#;
fs::write(test_dir.join("todo/1.md"), task1).unwrap();
let task2 = r#"# Test Task 2
id: 2
order: 2000
created: 1234567891
Another task.
"#;
fs::write(test_dir.join("done/2.md"), task2).unwrap();
let result = auto_migrate_project_to_new_format(&test_dir);
assert!(result.is_ok());
assert_eq!(result.unwrap(), true);
assert!(test_dir.join("tasks.toml").exists());
let metadata = load_tasks_metadata(&test_dir).unwrap();
assert_eq!(metadata.len(), 2);
assert!(metadata.contains_key("1"));
assert!(metadata.contains_key("2"));
let task1_meta = metadata.get("1").unwrap();
assert_eq!(task1_meta.title, "Test Task 1");
assert_eq!(task1_meta.status, "todo");
assert_eq!(task1_meta.priority, Some("high".to_string()));
assert_eq!(task1_meta.tags, vec!["test", "feature"]);
let content1 = fs::read_to_string(test_dir.join("todo/1.md")).unwrap();
assert!(!content1.contains("id:"));
assert!(!content1.contains("order:"));
assert!(content1.contains("This is task content."));
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_auto_migration_already_new_format() {
let temp_dir = setup_metadata_project();
let project_path = temp_dir.path();
let result = auto_migrate_project_to_new_format(project_path);
assert!(result.is_ok());
assert_eq!(result.unwrap(), false);
}
#[test]
fn test_auto_migration_empty_project() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let result = auto_migrate_project_to_new_format(project_path);
assert!(result.is_ok());
assert_eq!(result.unwrap(), true);
assert!(project_path.join("tasks.toml").exists());
}
#[test]
fn test_load_tasks_metadata_empty_file() {
let temp_dir = tempfile::tempdir().unwrap();
let project_path = temp_dir.path();
fs::write(project_path.join("tasks.toml"), "").unwrap();
let result = load_tasks_metadata(project_path);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_load_tasks_metadata_old_tasks_section_format() {
let temp_dir = tempfile::tempdir().unwrap();
let project_path = temp_dir.path();
fs::write(project_path.join("tasks.toml"), "[tasks]").unwrap();
let result = load_tasks_metadata(project_path);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_save_task_with_metadata_project_migrates() {
let temp_dir = setup_metadata_project();
let project_path = temp_dir.path();
let mut task = Task::new(99, "New Task".to_string(), "todo".to_string());
task.order = 99000;
task.priority = Some("high".to_string());
save_task(project_path, &task).unwrap();
assert!(!project_path.join("tasks.toml").exists());
let content = fs::read_to_string(project_path.join("todo/99.md")).unwrap();
assert!(content.starts_with("+++"));
assert!(content.contains("id = 99"));
assert!(content.contains("priority = \"high\""));
}
#[test]
fn test_move_task_with_frontmatter() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let mut task = Task::new(1, "Test Task".to_string(), "todo".to_string());
task.order = 1000;
save_task(project_path, &task).unwrap();
let tasks = load_tasks_from_dir(&project_path.join("todo"), "todo").unwrap();
assert!(!tasks.is_empty());
let task = &tasks[0];
let original_id = task.id;
move_task(project_path, task, "done").unwrap();
assert!(project_path.join("done").join(format!("{}.md", original_id)).exists());
assert!(!project_path.join("todo").join(format!("{}.md", original_id)).exists());
let content = fs::read_to_string(project_path.join("done/1.md")).unwrap();
assert!(content.starts_with("+++"));
}
#[test]
fn test_save_task_frontmatter_format() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let task = Task {
id: 1,
order: 1000,
title: "Frontmatter Task".to_string(),
content: "Task content here.".to_string(),
created: "1234567890".to_string(),
priority: Some("high".to_string()),
status: "todo".to_string(),
tags: vec!["feature".to_string(), "urgent".to_string()],
file_path: PathBuf::new(),
};
let result = save_task(project_path, &task);
assert!(result.is_ok());
let saved_path = result.unwrap();
assert!(saved_path.exists());
assert_eq!(saved_path, project_path.join("todo/1.md"));
let content = fs::read_to_string(&saved_path).unwrap();
assert!(content.starts_with("+++"));
assert!(content.contains("id = 1"));
assert!(content.contains("order = 1000"));
assert!(content.contains("created = \"1234567890\""));
assert!(content.contains("priority = \"high\""));
assert!(content.contains("tags = ["));
assert!(content.contains("# Frontmatter Task"));
assert!(content.contains("Task content here."));
assert!(!project_path.join("tasks.toml").exists());
}
#[test]
fn test_load_tasks_from_frontmatter() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let task_content = r#"+++
id = 1
order = 1000
created = "1234567890"
priority = "high"
tags = ["feature", "urgent"]
+++
# Test Frontmatter Task
This is the task content.
"#;
fs::write(project_path.join("todo/1.md"), task_content).unwrap();
let tasks = load_tasks_from_dir(&project_path.join("todo"), "todo").unwrap();
assert_eq!(tasks.len(), 1);
let task = &tasks[0];
assert_eq!(task.id, 1);
assert_eq!(task.order, 1000);
assert_eq!(task.title, "Test Frontmatter Task");
assert_eq!(task.status, "todo");
assert_eq!(task.priority, Some("high".to_string()));
assert_eq!(task.tags, vec!["feature", "urgent"]);
assert!(task.content.contains("This is the task content."));
}
#[test]
fn test_frontmatter_recovery() {
let temp_dir = setup_legacy_project();
let project_path = temp_dir.path();
let corrupted_content = r#"+++
order = 1000
created = "1234567890"
+++
# Recovered Task
Content here.
"#;
fs::write(project_path.join("todo/42.md"), corrupted_content).unwrap();
let tasks = load_tasks_from_dir(&project_path.join("todo"), "todo").unwrap();
assert_eq!(tasks.len(), 1);
let task = &tasks[0];
assert_eq!(task.id, 42); assert_eq!(task.title, "Recovered Task");
}
}