use crate::models::Status;
use std::fs;
use std::path::Path;
#[allow(dead_code)]
pub fn validate_status_name(name: &str, existing_statuses: &[Status]) -> Result<(), String> {
if name.trim().is_empty() {
return Err("状态名称不能为空".to_string());
}
if name.len() > 50 {
return Err("状态名称不能超过50个字符".to_string());
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err("状态名称只能包含字母、数字、下划线和连字符".to_string());
}
if let Some(first_char) = name.chars().next()
&& !first_char.is_ascii_alphanumeric() {
return Err("状态名称必须以字母或数字开头".to_string());
}
if existing_statuses.iter().any(|s| s.name == name) {
return Err(format!("状态 '{}' 已存在", name));
}
let reserved = [".", "..", ".kanban", ".git"];
if reserved.contains(&name.to_lowercase().as_str()) {
return Err("该名称为系统保留名称".to_string());
}
Ok(())
}
#[allow(dead_code)]
pub fn validate_display_name(display: &str) -> Result<(), String> {
if display.trim().is_empty() {
return Err("显示名称不能为空".to_string());
}
if display.chars().count() > 50 {
return Err("显示名称不能超过50个字符".to_string());
}
Ok(())
}
pub fn create_status(
project_path: &Path,
status_name: &str,
display_name: &str,
) -> Result<(), String> {
let status_dir = project_path.join(status_name);
fs::create_dir_all(&status_dir).map_err(|e| format!("创建目录失败: {}", e))?;
let mut config = super::load_project_config(project_path)?;
config.statuses.order.push(status_name.to_string());
config.statuses.statuses.insert(
status_name.to_string(),
crate::models::StatusConfig {
display: display_name.to_string(),
},
);
super::save_project_config(project_path, &config)?;
Ok(())
}
pub fn rename_status(
project_path: &Path,
old_name: &str,
new_name: &str,
new_display: &str,
) -> Result<(), String> {
let old_dir = project_path.join(old_name);
let new_dir = project_path.join(new_name);
if !old_dir.exists() {
return Err(format!("状态目录 '{}' 不存在", old_name));
}
if new_dir.exists() && new_dir != old_dir {
return Err(format!("目标目录 '{}' 已存在", new_name));
}
if old_dir != new_dir {
fs::rename(&old_dir, &new_dir).map_err(|e| format!("重命名目录失败: {}", e))?;
}
let tasks_toml = project_path.join("tasks.toml");
if tasks_toml.exists() && old_name != new_name {
let mut metadata_map = crate::fs::task::load_tasks_metadata(project_path)?;
let mut updated = false;
for (_id, metadata) in metadata_map.iter_mut() {
if metadata.status == old_name {
metadata.status = new_name.to_string();
updated = true;
}
}
if updated {
crate::fs::task::save_tasks_metadata(project_path, &metadata_map)?;
}
}
let mut config = super::load_project_config(project_path)?;
if let Some(pos) = config.statuses.order.iter().position(|s| s == old_name) {
config.statuses.order[pos] = new_name.to_string();
}
config.statuses.statuses.remove(old_name);
config.statuses.statuses.insert(
new_name.to_string(),
crate::models::StatusConfig {
display: new_display.to_string(),
},
);
super::save_project_config(project_path, &config)?;
Ok(())
}
pub fn update_status_display(
project_path: &Path,
status_name: &str,
new_display: &str,
) -> Result<(), String> {
let mut config = super::load_project_config(project_path)?;
if let Some(status_config) = config.statuses.statuses.get_mut(status_name) {
status_config.display = new_display.to_string();
} else {
return Err(format!("找不到状态 '{}'", status_name));
}
super::save_project_config(project_path, &config)?;
Ok(())
}
pub fn delete_status(
project_path: &Path,
status_name: &str,
move_to_status: Option<&str>,
) -> Result<(), String> {
let status_dir = project_path.join(status_name);
if let Some(target_status) = move_to_status {
let target_dir = project_path.join(target_status);
if !target_dir.exists() {
fs::create_dir_all(&target_dir).map_err(|e| format!("创建目标目录失败: {}", e))?;
}
if status_dir.exists() {
for entry in fs::read_dir(&status_dir).map_err(|e| format!("读取目录失败: {}", e))?
{
let entry = entry.map_err(|e| format!("读取文件失败: {}", e))?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("md") {
let filename = path.file_name().unwrap();
let target_path = target_dir.join(filename);
fs::rename(&path, &target_path).map_err(|e| format!("移动任务失败: {}", e))?;
}
}
}
}
if status_dir.exists() {
fs::remove_dir_all(&status_dir).map_err(|e| format!("删除目录失败: {}", e))?;
}
let mut config = super::load_project_config(project_path)?;
config.statuses.order.retain(|s| s != status_name);
config.statuses.statuses.remove(status_name);
super::save_project_config(project_path, &config)?;
Ok(())
}
pub fn move_status_order(
project_path: &Path,
status_name: &str,
direction: i32, ) -> Result<(), String> {
let mut config = super::load_project_config(project_path)?;
let current_index = config
.statuses
.order
.iter()
.position(|s| s == status_name)
.ok_or_else(|| format!("找不到状态 '{}'", status_name))?;
let new_index = (current_index as i32 + direction)
.max(0)
.min(config.statuses.order.len() as i32 - 1) as usize;
if current_index == new_index {
return Ok(());
}
let status = config.statuses.order.remove(current_index);
config.statuses.order.insert(new_index, status);
super::save_project_config(project_path, &config)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup_test_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
}
#[test]
fn test_validate_status_name_empty() {
let existing: Vec<Status> = vec![];
assert!(validate_status_name("", &existing).is_err());
assert!(validate_status_name(" ", &existing).is_err());
}
#[test]
fn test_validate_status_name_too_long() {
let existing: Vec<Status> = vec![];
let long_name = "a".repeat(51);
assert!(validate_status_name(&long_name, &existing).is_err());
}
#[test]
fn test_validate_status_name_invalid_chars() {
let existing: Vec<Status> = vec![];
assert!(validate_status_name("hello world", &existing).is_err()); assert!(validate_status_name("hello@world", &existing).is_err()); assert!(validate_status_name("你好", &existing).is_err()); }
#[test]
fn test_validate_status_name_must_start_with_alphanumeric() {
let existing: Vec<Status> = vec![];
assert!(validate_status_name("_test", &existing).is_err());
assert!(validate_status_name("-test", &existing).is_err());
}
#[test]
fn test_validate_status_name_duplicate() {
let existing = vec![Status {
name: "todo".to_string(),
display: "Todo".to_string(),
}];
assert!(validate_status_name("todo", &existing).is_err());
}
#[test]
fn test_validate_status_name_reserved() {
let existing: Vec<Status> = vec![];
assert!(validate_status_name(".kanban", &existing).is_err());
assert!(validate_status_name(".git", &existing).is_err());
}
#[test]
fn test_validate_status_name_valid() {
let existing: Vec<Status> = vec![];
assert!(validate_status_name("todo", &existing).is_ok());
assert!(validate_status_name("in-progress", &existing).is_ok());
assert!(validate_status_name("done_2024", &existing).is_ok());
assert!(validate_status_name("A1", &existing).is_ok());
}
#[test]
fn test_validate_display_name_empty() {
assert!(validate_display_name("").is_err());
assert!(validate_display_name(" ").is_err());
}
#[test]
fn test_validate_display_name_too_long() {
let long_name = "啊".repeat(51);
assert!(validate_display_name(&long_name).is_err());
}
#[test]
fn test_validate_display_name_valid() {
assert!(validate_display_name("Todo").is_ok());
assert!(validate_display_name("进行中").is_ok());
assert!(validate_display_name("Done ✓").is_ok());
}
#[test]
fn test_create_status() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let result = create_status(project_path, "review", "Review");
assert!(result.is_ok());
assert!(project_path.join("review").exists());
let config = crate::fs::load_project_config(project_path).unwrap();
assert!(config.statuses.order.contains(&"review".to_string()));
assert!(config.statuses.statuses.contains_key("review"));
assert_eq!(
config.statuses.statuses.get("review").unwrap().display,
"Review"
);
}
#[test]
fn test_rename_status() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(project_path.join("todo/1.md"), "Test task").unwrap();
let result = rename_status(project_path, "todo", "backlog", "Backlog");
assert!(result.is_ok());
assert!(!project_path.join("todo").exists());
assert!(project_path.join("backlog").exists());
assert!(project_path.join("backlog/1.md").exists());
let config = crate::fs::load_project_config(project_path).unwrap();
assert!(!config.statuses.order.contains(&"todo".to_string()));
assert!(config.statuses.order.contains(&"backlog".to_string()));
}
#[test]
fn test_update_status_display() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let result = update_status_display(project_path, "todo", "待办事项");
assert!(result.is_ok());
let config = crate::fs::load_project_config(project_path).unwrap();
assert_eq!(
config.statuses.statuses.get("todo").unwrap().display,
"待办事项"
);
}
#[test]
fn test_update_status_display_not_found() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let result = update_status_display(project_path, "nonexistent", "Test");
assert!(result.is_err());
}
#[test]
fn test_delete_status() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let result = delete_status(project_path, "doing", None);
assert!(result.is_ok());
assert!(!project_path.join("doing").exists());
let config = crate::fs::load_project_config(project_path).unwrap();
assert!(!config.statuses.order.contains(&"doing".to_string()));
assert!(!config.statuses.statuses.contains_key("doing"));
}
#[test]
fn test_delete_status_with_move() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
fs::write(project_path.join("doing/1.md"), "Task 1").unwrap();
fs::write(project_path.join("doing/2.md"), "Task 2").unwrap();
let result = delete_status(project_path, "doing", Some("done"));
assert!(result.is_ok());
assert!(project_path.join("done/1.md").exists());
assert!(project_path.join("done/2.md").exists());
assert!(!project_path.join("doing").exists());
}
#[test]
fn test_move_status_order_left() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let result = move_status_order(project_path, "doing", -1);
assert!(result.is_ok());
let config = crate::fs::load_project_config(project_path).unwrap();
assert_eq!(config.statuses.order, vec!["doing", "todo", "done"]);
}
#[test]
fn test_move_status_order_right() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let result = move_status_order(project_path, "doing", 1);
assert!(result.is_ok());
let config = crate::fs::load_project_config(project_path).unwrap();
assert_eq!(config.statuses.order, vec!["todo", "done", "doing"]);
}
#[test]
fn test_move_status_order_to_first() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let result = move_status_order(project_path, "done", -2);
assert!(result.is_ok());
let config = crate::fs::load_project_config(project_path).unwrap();
assert_eq!(config.statuses.order, vec!["done", "todo", "doing"]);
}
#[test]
fn test_move_status_order_to_last() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let result = move_status_order(project_path, "todo", 2);
assert!(result.is_ok());
let config = crate::fs::load_project_config(project_path).unwrap();
assert_eq!(config.statuses.order, vec!["doing", "done", "todo"]);
}
#[test]
fn test_move_status_order_boundary() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let result = move_status_order(project_path, "todo", -1);
assert!(result.is_ok());
let config = crate::fs::load_project_config(project_path).unwrap();
assert_eq!(config.statuses.order, vec!["todo", "doing", "done"]);
let result = move_status_order(project_path, "done", 1);
assert!(result.is_ok());
let config = crate::fs::load_project_config(project_path).unwrap();
assert_eq!(config.statuses.order, vec!["todo", "doing", "done"]);
}
#[test]
fn test_move_status_order_not_found() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let result = move_status_order(project_path, "nonexistent", 1);
assert!(result.is_err());
}
#[test]
fn test_rename_status_updates_tasks_toml() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let tasks_content = r#"[1]
id = 1
order = 1000
title = "Task 1"
status = "todo"
created = "1234567890"
tags = []
[2]
id = 2
order = 2000
title = "Task 2"
status = "todo"
created = "1234567891"
tags = []
[3]
id = 3
order = 3000
title = "Task 3"
status = "doing"
created = "1234567892"
tags = []
"#;
fs::write(project_path.join("tasks.toml"), tasks_content).unwrap();
fs::write(project_path.join("todo/1.md"), "Task 1 content").unwrap();
fs::write(project_path.join("todo/2.md"), "Task 2 content").unwrap();
fs::write(project_path.join("doing/3.md"), "Task 3 content").unwrap();
let result = rename_status(project_path, "todo", "backlog", "Backlog");
assert!(result.is_ok());
let metadata = crate::fs::task::load_tasks_metadata(project_path).unwrap();
assert_eq!(metadata.get("1").unwrap().status, "backlog");
assert_eq!(metadata.get("2").unwrap().status, "backlog");
assert_eq!(metadata.get("3").unwrap().status, "doing");
}
}