use std::fs;
use std::path::{Path, PathBuf};
use crate::models::{Project, ProjectConfig, ProjectType, Status, StatusConfig};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Default)]
struct LocalProjectsIndex {
local_projects: Vec<String>,
}
fn get_local_projects_index_path() -> PathBuf {
get_data_dir().join("local_projects.toml")
}
fn load_local_projects_index() -> LocalProjectsIndex {
let index_path = get_local_projects_index_path();
if !index_path.exists() {
return LocalProjectsIndex::default();
}
match fs::read_to_string(&index_path) {
Ok(content) => toml::from_str(&content).unwrap_or_default(),
Err(_) => LocalProjectsIndex::default(),
}
}
fn save_local_projects_index(index: &LocalProjectsIndex) -> std::io::Result<()> {
let index_path = get_local_projects_index_path();
if let Some(parent) = index_path.parent() {
fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(index)
.map_err(std::io::Error::other)?;
fs::write(&index_path, content)
}
pub fn add_local_project_to_index(project_path: &Path) -> std::io::Result<()> {
let mut index = load_local_projects_index();
let path_str = project_path.to_string_lossy().to_string();
if !index.local_projects.contains(&path_str) {
index.local_projects.push(path_str);
save_local_projects_index(&index)?;
}
Ok(())
}
fn clean_and_get_valid_paths() -> std::io::Result<Vec<PathBuf>> {
let mut index = load_local_projects_index();
let mut valid_paths = Vec::new();
let mut changed = false;
index.local_projects.retain(|path_str| {
let path = PathBuf::from(path_str);
let exists = path.exists() && path.join(".kanban.toml").exists();
if exists {
valid_paths.push(path);
} else {
changed = true; }
exists
});
if changed {
save_local_projects_index(&index)?;
}
Ok(valid_paths)
}
pub fn get_data_dir() -> PathBuf {
let home_dir = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.expect("Failed to get home directory");
PathBuf::from(home_dir).join(".kanban")
}
pub fn get_projects_dir() -> PathBuf {
get_data_dir().join("projects")
}
pub fn get_local_kanban_dir() -> PathBuf {
std::env::current_dir()
.expect("Failed to get current directory")
.join(".kanban")
}
pub fn init_data_dir() -> std::io::Result<()> {
let projects_dir = get_projects_dir();
if !projects_dir.exists() {
fs::create_dir_all(&projects_dir)?;
}
Ok(())
}
pub fn list_project_dirs() -> std::io::Result<Vec<PathBuf>> {
let projects_dir = get_projects_dir();
if !projects_dir.exists() {
return Ok(Vec::new());
}
let mut project_paths = Vec::new();
for entry in fs::read_dir(projects_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if path.join(".kanban.toml").exists() {
project_paths.push(path);
}
}
}
Ok(project_paths)
}
pub fn list_local_project_dirs() -> std::io::Result<Vec<PathBuf>> {
let mut all_paths = Vec::new();
let local_kanban_dir = get_local_kanban_dir();
if local_kanban_dir.exists() && local_kanban_dir.join(".kanban.toml").exists() {
all_paths.push(local_kanban_dir.clone());
let _ = add_local_project_to_index(&local_kanban_dir);
}
let indexed_paths = clean_and_get_valid_paths()?;
for path in indexed_paths {
if !all_paths.contains(&path) {
all_paths.push(path);
}
}
Ok(all_paths)
}
pub fn load_project_config(project_path: &Path) -> Result<ProjectConfig, String> {
let config_path = project_path.join(".kanban.toml");
if !config_path.exists() {
return Err(format!("Config file not found: {:?}", config_path));
}
let content =
fs::read_to_string(&config_path).map_err(|e| format!("Failed to read config: {}", e))?;
toml::from_str(&content).map_err(|e| {
let error_msg = format!("Failed to parse TOML: {}", e);
log_config_parse_error(&config_path, &error_msg, &content);
error_msg
})
}
fn log_config_parse_error(config_path: &Path, error_msg: &str, content: &str) {
use std::fs::OpenOptions;
use std::io::Write;
if let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open("/tmp/kanban_debug.log")
{
let _ = writeln!(
file,
"[{}] Config TOML Parse Error: {} ({})",
chrono::Local::now().format("%H:%M:%S"),
error_msg,
config_path.display()
);
let _ = writeln!(file, "Content:");
for line in content.lines() {
let _ = writeln!(file, " {}", line);
}
let _ = writeln!(file, "---");
}
}
pub fn load_project(project_path: &Path) -> Result<Project, String> {
load_project_with_type(project_path, ProjectType::Global)
}
pub fn load_project_with_type(
project_path: &Path,
project_type: ProjectType,
) -> Result<Project, String> {
let tasks_toml = project_path.join("tasks.toml");
if tasks_toml.exists() {
super::task::migrate_metadata_to_frontmatter(project_path)?;
}
let actual_dirs = scan_status_directories(project_path)?;
let mut config = load_project_config(project_path)?;
let config_updated = sync_status_config(&mut config, &actual_dirs);
if config_updated {
save_project_config(project_path, &config)?;
}
let mut statuses = Vec::new();
for status_name in &config.statuses.order {
if let Some(status_config) = config.statuses.statuses.get(status_name) {
statuses.push(Status::new(
status_name.clone(),
status_config.display.clone(),
));
}
}
let mut project = Project::new(
config.name.clone(),
project_path.to_path_buf(),
project_type,
);
project.statuses = statuses;
for status in &project.statuses {
let status_dir = project_path.join(&status.name);
if status_dir.exists()
&& let Ok(tasks) = super::task::load_tasks_from_dir(&status_dir, &status.name) {
project.tasks.extend(tasks);
}
}
Ok(project)
}
fn scan_status_directories(project_path: &Path) -> Result<Vec<String>, String> {
if !project_path.exists() {
return Ok(Vec::new());
}
let mut dirs = Vec::new();
for entry in fs::read_dir(project_path)
.map_err(|e| format!("Failed to read project directory: {}", e))?
{
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
let path = entry.path();
if path.is_dir()
&& let Some(name) = path.file_name() {
let name_str = name.to_string_lossy().to_string();
if !name_str.starts_with('.') {
dirs.push(name_str);
}
}
}
dirs.sort();
Ok(dirs)
}
fn sync_status_config(config: &mut ProjectConfig, actual_dirs: &[String]) -> bool {
let mut updated = false;
let original_len = config.statuses.order.len();
config.statuses.order.retain(|s| actual_dirs.contains(s));
if config.statuses.order.len() != original_len {
updated = true;
}
let keys_to_remove: Vec<String> = config
.statuses
.statuses
.keys()
.filter(|k| !actual_dirs.contains(k))
.cloned()
.collect();
for key in keys_to_remove {
config.statuses.statuses.remove(&key);
updated = true;
}
for dir in actual_dirs {
if !config.statuses.order.contains(dir) {
config.statuses.order.push(dir.clone());
config.statuses.statuses.insert(
dir.clone(),
StatusConfig {
display: capitalize_first(dir),
},
);
updated = true;
}
}
updated
}
fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => {
let mut result = first.to_uppercase().to_string();
result.push_str(chars.as_str());
result
}
}
}
pub fn save_project_config(project_path: &Path, config: &ProjectConfig) -> Result<(), String> {
let config_path = project_path.join(".kanban.toml");
let content =
toml::to_string_pretty(config).map_err(|e| format!("Failed to serialize config: {}", e))?;
fs::write(&config_path, content).map_err(|e| format!("Failed to write config: {}", e))?;
Ok(())
}
pub fn create_project(name: &str) -> Result<PathBuf, String> {
let project_dir = get_projects_dir().join(name);
if project_dir.exists() {
return Err(format!("Project '{}' already exists", name));
}
fs::create_dir_all(&project_dir)
.map_err(|e| format!("Failed to create project directory: {}", e))?;
let default_statuses = vec!["todo", "doing", "done"];
for status in &default_statuses {
fs::create_dir_all(project_dir.join(status))
.map_err(|e| format!("Failed to create status directory: {}", e))?;
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let config = format!(
concat!(
"name = \"{}\"\n",
"created = \"{}\"\n",
"\n",
"[statuses]\n",
"order = [\"todo\", \"doing\", \"done\"]\n",
"\n",
"[statuses.todo]\n",
"display = \"Todo\"\n",
"\n",
"[statuses.doing]\n",
"display = \"Doing\"\n",
"\n",
"[statuses.done]\n",
"display = \"Done\"\n"
),
name, timestamp
);
fs::write(project_dir.join(".kanban.toml"), config)
.map_err(|e| format!("Failed to write config: {}", e))?;
fs::write(project_dir.join("tasks.toml"), "")
.map_err(|e| format!("Failed to create tasks.toml: {}", e))?;
Ok(project_dir)
}
pub fn create_local_project(name: &str) -> Result<PathBuf, String> {
let project_dir = get_local_kanban_dir();
if project_dir.exists() {
return Err("本地看板已存在,一个目录只能有一个本地项目".to_string());
}
fs::create_dir_all(&project_dir)
.map_err(|e| format!("Failed to create .kanban directory: {}", e))?;
let default_statuses = vec!["todo", "doing", "done"];
for status in &default_statuses {
fs::create_dir_all(project_dir.join(status))
.map_err(|e| format!("Failed to create status directory: {}", e))?;
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let config = format!(
concat!(
"name = \"{}\"\n",
"created = \"{}\"\n",
"\n",
"[statuses]\n",
"order = [\"todo\", \"doing\", \"done\"]\n",
"\n",
"[statuses.todo]\n",
"display = \"Todo\"\n",
"\n",
"[statuses.doing]\n",
"display = \"Doing\"\n",
"\n",
"[statuses.done]\n",
"display = \"Done\"\n"
),
name, timestamp
);
fs::write(project_dir.join(".kanban.toml"), config)
.map_err(|e| format!("Failed to write config: {}", e))?;
fs::write(project_dir.join("tasks.toml"), "")
.map_err(|e| format!("Failed to create tasks.toml: {}", e))?;
let _ = add_local_project_to_index(&project_dir);
Ok(project_dir)
}
#[allow(dead_code)]
pub fn rename_project(old_name: &str, new_name: &str) -> Result<(), String> {
let projects_dir = get_projects_dir();
let old_path = projects_dir.join(old_name);
let new_path = projects_dir.join(new_name);
if !old_path.exists() {
return Err(format!("Project '{}' does not exist", old_name));
}
if new_path.exists() {
return Err(format!("Project '{}' already exists", new_name));
}
fs::rename(&old_path, &new_path)
.map_err(|e| format!("Failed to rename project directory: {}", e))?;
let config_path = new_path.join(".kanban.toml");
let content =
fs::read_to_string(&config_path).map_err(|e| format!("Failed to read config: {}", e))?;
let mut config: ProjectConfig =
toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
config.name = new_name.to_string();
let new_content =
toml::to_string(&config).map_err(|e| format!("Failed to serialize TOML: {}", e))?;
fs::write(config_path, new_content).map_err(|e| format!("Failed to write config: {}", e))?;
Ok(())
}
pub fn delete_project_by_path(project_path: &std::path::Path) -> Result<(), String> {
if !project_path.exists() {
return Err(format!("项目路径 '{}' 不存在", project_path.display()));
}
std::fs::remove_dir_all(project_path).map_err(|e| format!("删除项目目录失败: {}", e))?;
Ok(())
}
#[allow(dead_code)]
pub fn delete_project(project_name: &str, project_type: &ProjectType) -> Result<(), String> {
let project_dir = match project_type {
ProjectType::Global => get_projects_dir().join(project_name),
ProjectType::Local => get_local_kanban_dir().join(project_name),
};
if !project_dir.exists() {
return Err(format!("项目 '{}' 不存在", project_name));
}
std::fs::remove_dir_all(&project_dir).map_err(|e| format!("删除项目目录失败: {}", e))?;
Ok(())
}
pub fn ensure_global_claude_md() -> Result<(), String> {
let claude_md_path = get_data_dir().join("CLAUDE.md");
if claude_md_path.exists() {
return Ok(());
}
let template = r##"# Helix Kanban AI 操作指南
## 获取项目信息
TUI 中按 `Space` → `p` → `i` 复制项目信息(类型、名称、看板路径、本文档路径)
## 存储格式
**tasks.toml**(元数据):
```toml
[1]
id = 1
order = 1000
title = "任务标题"
status = "todo"
created = "1234567890"
priority = "high" # high/medium/low/none
tags = ["feature", "urgent"]
```
**{status}/{id}.md**(纯内容):
```markdown
任务描述内容
## 子任务
- [ ] 子任务 1
```
## CLI 命令
```bash
# 列出项目
hxk project list
# 列出任务
hxk task list <项目名> --status todo
# 查看任务详情
hxk task show <项目名> <task-id>
# 创建任务
hxk task create <项目名> --status todo --title "任务标题"
# 更新任务
hxk task update <项目名> <task-id> --priority high --tags "api,urgent"
# 移动任务
hxk task move <项目名> <task-id> --to doing
# 删除任务
hxk task delete <项目名> <task-id>
```
## 注意事项
- UTF-8 编码
- 任务 ID 为唯一数字
- 时间戳为 Unix 时间戳(秒)
- TUI 快捷键:`Space+p+i` 复制项目信息,`Y` 复制任务内容
"##;
std::fs::write(&claude_md_path, template)
.map_err(|e| format!("创建 CLAUDE.md 失���: {}", e))?;
Ok(())
}
pub fn ensure_global_ai_config() -> Result<(), String> {
let ai_config_path = get_data_dir().join(".ai-config.json");
if ai_config_path.exists() {
return Ok(());
}
let version = env!("CARGO_PKG_VERSION");
let template = r##"{
"project_type": "helix-kanban",
"version": "VERSION",
"description": "这是一个基于文件系统的看板项目,任务以 Markdown 文件形式存储",
"important_notes": [
"⚠️ 所有任务操作必须在看板目录中进行(即包含 .kanban.toml 的目录)",
"⚠️ 看板路径可通过 hxk 应用中按 Space+p+i 获取",
"⚠️ 不要在项目代码目录中直接操作任务文件"
],
"how_to_find_kanban_dir": {
"method_1": "在 hxk 应用中,按 Space → p → i 复制项目信息,包含看板路径",
"method_2": "全局项目:~/.kanban/projects/项目名/",
"method_3": "本地项目:项目代码目录/.kanban/",
"example_global": "cd ~/.kanban/projects/匹达代驾",
"example_local": "cd /path/to/your/project/.kanban"
},
"ai_instructions": {
"create_task": {
"description": "在看板目录的指定状态目录创建新任务",
"command": "cd <看板目录> && 在 {status}/ 目录创建 {next_id}.md",
"format": "# {title}\n\ncreated: {timestamp}\npriority: {priority}\n\n{description}",
"example": "cd ~/.kanban/projects/myproject && echo '# 实现用户登录\\n\\ncreated: 2024-12-17\\npriority: high\\n\\n' > todo/005.md",
"notes": [
"任务编号从 001 开始,自动递增",
"时间戳使用 ISO 8601 格式或 Unix 时间戳",
"优先级: high, medium, low"
]
},
"move_task": {
"description": "移动任务到另一个状态",
"command": "cd <看板目录> && mv {from}/{file}.md {to}/",
"example": "cd ~/.kanban/projects/myproject && mv todo/001.md doing/",
"notes": [
"保持文件名不变",
"可用状态: todo, doing, done(以 .kanban.toml 为准)"
]
},
"list_tasks": {
"description": "列出指定状态的所有任务",
"command": "cd <看板目录> && ls {status}/*.md | xargs head -n 1",
"example": "cd ~/.kanban/projects/myproject && ls todo/*.md",
"notes": [
"head -n 1 只显示标题(第一行)",
"可以用 cat 查看完整内容"
]
},
"complete_task": {
"description": "完成任务(移到 done)",
"command": "cd <看板目录> && mv {from}/{file}.md done/",
"example": "cd ~/.kanban/projects/myproject && mv doing/003.md done/"
},
"delete_task": {
"description": "删除任务文件",
"command": "cd <看板目录> && rm {status}/{file}.md",
"example": "cd ~/.kanban/projects/myproject && rm todo/002.md",
"warning": "删除操作不可恢复,请谨慎使用"
}
},
"project_structure": {
"root": "<看板目录>/",
"config": ".kanban.toml",
"statuses": [
"todo/ - 待办任务",
"doing/ - 进行中",
"done/ - 已完成"
],
"task_files": "{status}/001.md, 002.md, 003.md..."
},
"quick_commands": {
"get_kanban_path": "在 hxk 中按 Space+p+i 复制看板路径",
"cd_to_kanban": "cd <看板路径>",
"add_task": "cd <看板路径> && 创建任务文件",
"move_task": "cd <看板路径> && mv {from}/{file}.md {to}/",
"list": "cd <看板路径> && ls {status}/*.md"
},
"task_format": {
"header": "# 任务标题",
"metadata": [
"created: 2024-12-16T10:00:00+08:00",
"priority: high|medium|low"
],
"body": "任务的详细描述...",
"example": "# 实现用户登录\\n\\ncreated: 2024-12-16T10:00:00+08:00\\npriority: high\\n\\n实现用户登录功能,支持邮箱和手机号登录。"
},
"tips": [
"使用 'cat .kanban.toml' 查看项目配置和状态列表(在看板目录中)",
"任务编号是文件名,如 001.md, 002.md",
"可以直接编辑任务文件,应用会自动重新加载",
"Y 键可以复制任务内容到剪贴板,方便分享给 AI",
"Space+p+i 可以复制项目的看板路径和 AI 配置路径"
]
}"##;
let config_content = template.replace("VERSION", version);
std::fs::write(&ai_config_path, config_content)
.map_err(|e| format!("创建 AI 配置文件失败: {}", e))?;
Ok(())
}