Skip to main content

j_cli/command/
report.rs

1use crate::config::YamlConfig;
2use crate::constants::{section, config_key, search_flag, rmeta_action, REPORT_DATE_FORMAT, REPORT_SIMPLE_DATE_FORMAT, DEFAULT_CHECK_LINES};
3use crate::util::fuzzy;
4use crate::{error, info, usage};
5use chrono::{Local, NaiveDate};
6use colored::Colorize;
7use std::fs;
8use std::io::{Read, Seek, SeekFrom};
9use std::path::Path;
10use std::process::Command;
11
12const DATE_FORMAT: &str = REPORT_DATE_FORMAT;
13const SIMPLE_DATE_FORMAT: &str = REPORT_SIMPLE_DATE_FORMAT;
14
15// ========== report 命令 ==========
16
17/// 处理 report 命令: j report <content...> 或 j reportctl new [date] / j reportctl sync [date]
18pub fn handle_report(sub: &str, content: &[String], config: &mut YamlConfig) {
19    if content.is_empty() {
20        if sub == "reportctl" {
21            usage!("j reportctl new [date] | j reportctl sync [date] | j reportctl push | j reportctl pull | j reportctl set-url <url> | j reportctl open");
22            return;
23        }
24        // report 无参数:打开 TUI 多行编辑器(预填历史 + 日期前缀,NORMAL 模式)
25        handle_report_tui(config);
26        return;
27    }
28
29    let first = content[0].as_str();
30
31    // 元数据操作
32    if sub == "reportctl" {
33        match first {
34            f if f == rmeta_action::NEW => {
35                let date_str = content.get(1).map(|s| s.as_str());
36                handle_week_update(date_str, config);
37            }
38            f if f == rmeta_action::SYNC => {
39                let date_str = content.get(1).map(|s| s.as_str());
40                handle_sync(date_str, config);
41            }
42            f if f == rmeta_action::PUSH => {
43                let msg = content.get(1).map(|s| s.as_str());
44                handle_push(msg, config);
45            }
46            f if f == rmeta_action::PULL => {
47                handle_pull(config);
48            }
49            f if f == rmeta_action::SET_URL => {
50                let url = content.get(1).map(|s| s.as_str());
51                handle_set_url(url, config);
52            }
53            f if f == rmeta_action::OPEN => {
54                handle_open_report(config);
55            }
56            _ => {
57                error!("❌ 未知的元数据操作: {},可选: {}, {}, {}, {}, {}, {}", first, rmeta_action::NEW, rmeta_action::SYNC, rmeta_action::PUSH, rmeta_action::PULL, rmeta_action::SET_URL, rmeta_action::OPEN);
58            }
59        }
60        return;
61    }
62
63    // 常规日报写入
64    let text = content.join(" ");
65    let text = text.trim().trim_matches('"').to_string();
66
67    if text.is_empty() {
68        error!("⚠️ 内容为空,无法写入");
69        return;
70    }
71
72    handle_daily_report(&text, config);
73}
74
75/// 获取日报文件路径(统一入口,自动创建目录和文件)
76fn get_report_path(config: &YamlConfig) -> Option<String> {
77    let report_path = config.report_file_path();
78
79    // 确保父目录存在
80    if let Some(parent) = report_path.parent() {
81        let _ = fs::create_dir_all(parent);
82    }
83
84    // 如果文件不存在则自动创建空文件
85    if !report_path.exists() {
86        if let Err(e) = fs::write(&report_path, "") {
87            error!("❌ 创建日报文件失败: {}", e);
88            return None;
89        }
90        info!("📄 已自动创建日报文件: {:?}", report_path);
91    }
92
93    Some(report_path.to_string_lossy().to_string())
94}
95
96/// 获取日报工作目录下的 settings.json 路径
97fn get_settings_json_path(report_path: &str) -> std::path::PathBuf {
98    Path::new(report_path).parent().unwrap().join("settings.json")
99}
100
101/// TUI 模式日报编辑:预加载历史 + 日期前缀,NORMAL 模式进入
102fn handle_report_tui(config: &mut YamlConfig) {
103    let report_path = match get_report_path(config) {
104        Some(p) => p,
105        None => return,
106    };
107
108    let config_path = get_settings_json_path(&report_path);
109    load_config_from_json_and_sync(&config_path, config);
110
111    // 检查是否需要新开一周(与 handle_daily_report 相同逻辑)
112    let now = Local::now().date_naive();
113    let week_num = config
114        .get_property(section::REPORT, config_key::WEEK_NUM)
115        .and_then(|s| s.parse::<i32>().ok())
116        .unwrap_or(1);
117    let last_day_str = config
118        .get_property(section::REPORT, config_key::LAST_DAY)
119        .cloned()
120        .unwrap_or_default();
121    let last_day = parse_date(&last_day_str);
122
123    // 先读取文件最后 3 行作为历史上下文(在任何写入之前读取)
124    let context_lines = 3;
125    let report_file = Path::new(&report_path);
126    let last_lines = read_last_n_lines(report_file, context_lines);
127
128    // 拼接编辑器初始内容:历史行 + (可选的新周标题) + 日期前缀行
129    let mut initial_lines: Vec<String> = last_lines.clone();
130
131    // 检查是否需要新开一周 → 只更新配置,不写入文件;新周标题放入编辑器
132    if let Some(last_day) = last_day {
133        if now > last_day {
134            let next_last_day = now + chrono::Duration::days(6);
135            let new_week_title = format!(
136                "# Week{}[{}-{}]",
137                week_num,
138                now.format(DATE_FORMAT),
139                next_last_day.format(DATE_FORMAT)
140            );
141            update_config_files(week_num + 1, &next_last_day, &config_path, config);
142            // 新周标题放入编辑器初始内容,不提前写入文件
143            initial_lines.push(new_week_title);
144        }
145    }
146
147    // 构造日期前缀行
148    let today_str = now.format(SIMPLE_DATE_FORMAT);
149    let date_prefix = format!("- 【{}】 ", today_str);
150    initial_lines.push(date_prefix);
151
152    // 打开带初始内容的编辑器(NORMAL 模式)
153    match crate::tui::editor::open_multiline_editor_with_content("📝 编辑日报", &initial_lines) {
154        Ok(Some(text)) => {
155            // 用户提交了内容
156            // 计算原始上下文有多少行(用于替换)
157            let original_context_count = last_lines.len();
158
159            // 从文件中去掉最后 N 行,再写入编辑器的全部内容
160            replace_last_n_lines(report_file, original_context_count, &text);
161
162            info!("✅ 日报已写入:{}", report_path);
163        }
164        Ok(None) => {
165            info!("已取消编辑");
166            // 文件未做任何修改(新周标题也没有写入)
167            // 配置文件中的 week_num/last_day 可能已更新,但下次进入时 now <= last_day 不会重复生成
168        }
169        Err(e) => {
170            error!("❌ 编辑器启动失败: {}", e);
171        }
172    }
173}
174
175/// 替换文件最后 N 行为新内容
176fn replace_last_n_lines(path: &Path, n: usize, new_content: &str) {
177    let content = match fs::read_to_string(path) {
178        Ok(c) => c,
179        Err(e) => {
180            error!("❌ 读取文件失败: {}", e);
181            return;
182        }
183    };
184
185    let all_lines: Vec<&str> = content.lines().collect();
186
187    // 保留前面的行(去掉最后 n 行)
188    let keep_count = if all_lines.len() > n {
189        all_lines.len() - n
190    } else {
191        0
192    };
193
194    let mut result = String::new();
195
196    // 写入保留的行
197    for line in &all_lines[..keep_count] {
198        result.push_str(line);
199        result.push('\n');
200    }
201
202    // 追加编辑器的内容
203    result.push_str(new_content);
204
205    // 确保文件以换行结尾
206    if !result.ends_with('\n') {
207        result.push('\n');
208    }
209
210    if let Err(e) = fs::write(path, &result) {
211        error!("❌ 写入文件失败: {}", e);
212    }
213}
214
215/// 写入日报
216fn handle_daily_report(content: &str, config: &mut YamlConfig) {
217    let report_path = match get_report_path(config) {
218        Some(p) => p,
219        None => return,
220    };
221
222    info!("📂 日报文件路径:{}", report_path);
223
224    let report_file = Path::new(&report_path);
225    let config_path = get_settings_json_path(&report_path);
226
227    load_config_from_json_and_sync(&config_path, config);
228
229    let now = Local::now().date_naive();
230
231    let week_num = config
232        .get_property(section::REPORT, config_key::WEEK_NUM)
233        .and_then(|s| s.parse::<i32>().ok())
234        .unwrap_or(1);
235
236    let last_day_str = config
237        .get_property(section::REPORT, config_key::LAST_DAY)
238        .cloned()
239        .unwrap_or_default();
240
241    let last_day = parse_date(&last_day_str);
242
243    match last_day {
244        Some(last_day) => {
245            if now > last_day {
246                // 进入新的一周
247                let next_last_day = now + chrono::Duration::days(6);
248                let new_week_title = format!(
249                    "# Week{}[{}-{}]\n",
250                    week_num,
251                    now.format(DATE_FORMAT),
252                    next_last_day.format(DATE_FORMAT)
253                );
254                update_config_files(week_num + 1, &next_last_day, &config_path, config);
255                append_to_file(report_file, &new_week_title);
256            }
257        }
258        None => {
259            error!("❌ 无法解析 last_day 日期: {}", last_day_str);
260            return;
261        }
262    }
263
264    let today_str = now.format(SIMPLE_DATE_FORMAT);
265    let log_entry = format!("- 【{}】 {}\n", today_str, content);
266    append_to_file(report_file, &log_entry);
267    info!("✅ 成功将内容写入:{}", report_path);
268}
269
270/// 处理 reportctl new 命令:开启新的一周
271fn handle_week_update(date_str: Option<&str>, config: &mut YamlConfig) {
272    let report_path = match get_report_path(config) {
273        Some(p) => p,
274        None => return,
275    };
276
277    let config_path = get_settings_json_path(&report_path);
278
279    let week_num = config
280        .get_property(section::REPORT, config_key::WEEK_NUM)
281        .and_then(|s| s.parse::<i32>().ok())
282        .unwrap_or(1);
283
284    let last_day_str = date_str
285        .map(|s| s.to_string())
286        .or_else(|| config.get_property(section::REPORT, config_key::LAST_DAY).cloned())
287        .unwrap_or_default();
288
289    match parse_date(&last_day_str) {
290        Some(last_day) => {
291            let next_last_day = last_day + chrono::Duration::days(7);
292            update_config_files(week_num + 1, &next_last_day, &config_path, config);
293        }
294        None => {
295            error!("❌ 更新周数失败,请检查日期字符串是否有误: {}", last_day_str);
296        }
297    }
298}
299
300/// 处理 reportctl sync 命令:同步周数和日期
301fn handle_sync(date_str: Option<&str>, config: &mut YamlConfig) {
302    let report_path = match get_report_path(config) {
303        Some(p) => p,
304        None => return,
305    };
306
307    let config_path = get_settings_json_path(&report_path);
308
309    load_config_from_json_and_sync(&config_path, config);
310
311    let week_num = config
312        .get_property(section::REPORT, config_key::WEEK_NUM)
313        .and_then(|s| s.parse::<i32>().ok())
314        .unwrap_or(1);
315
316    let last_day_str = date_str
317        .map(|s| s.to_string())
318        .or_else(|| config.get_property(section::REPORT, config_key::LAST_DAY).cloned())
319        .unwrap_or_default();
320
321    match parse_date(&last_day_str) {
322        Some(last_day) => {
323            update_config_files(week_num, &last_day, &config_path, config);
324        }
325        None => {
326            error!("❌ 更新周数失败,请检查日期字符串是否有误: {}", last_day_str);
327        }
328    }
329}
330
331/// 更新配置文件(YAML + JSON)
332fn update_config_files(
333    week_num: i32,
334    last_day: &NaiveDate,
335    config_path: &Path,
336    config: &mut YamlConfig,
337) {
338    let last_day_str = last_day.format(DATE_FORMAT).to_string();
339
340    // 更新 YAML 配置
341    config.set_property(section::REPORT, config_key::WEEK_NUM, &week_num.to_string());
342    config.set_property(section::REPORT, config_key::LAST_DAY, &last_day_str);
343    info!(
344        "✅ 更新YAML配置文件成功:周数 = {}, 周结束日期 = {}",
345        week_num, last_day_str
346    );
347
348    // 更新 JSON 配置
349    if config_path.exists() {
350        let json = serde_json::json!({
351            "week_num": week_num,
352            "last_day": last_day_str
353        });
354        match fs::write(config_path, json.to_string()) {
355            Ok(_) => info!(
356                "✅ 更新JSON配置文件成功:周数 = {}, 周结束日期 = {}",
357                week_num, last_day_str
358            ),
359            Err(e) => error!("❌ 更新JSON配置文件时出错: {}", e),
360        }
361    }
362}
363
364/// 从 JSON 配置文件读取并同步到 YAML
365fn load_config_from_json_and_sync(config_path: &Path, config: &mut YamlConfig) {
366    if !config_path.exists() {
367        error!("❌ 日报配置文件不存在:{:?}", config_path);
368        return;
369    }
370
371    match fs::read_to_string(config_path) {
372        Ok(content) => {
373            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
374                let last_day = json
375                    .get("last_day")
376                    .and_then(|v| v.as_str())
377                    .unwrap_or("");
378                let week_num = json.get("week_num").and_then(|v| v.as_i64()).unwrap_or(1);
379
380                info!(
381                    "✅ 从日报配置文件中读取到:last_day = {}, week_num = {}",
382                    last_day, week_num
383                );
384
385                if let Some(last_day_date) = parse_date(last_day) {
386                    update_config_files(week_num as i32, &last_day_date, config_path, config);
387                }
388            } else {
389                error!("❌ 解析日报配置文件时出错");
390            }
391        }
392        Err(e) => error!("❌ 读取日报配置文件失败: {}", e),
393    }
394}
395
396fn parse_date(s: &str) -> Option<NaiveDate> {
397    NaiveDate::parse_from_str(s, DATE_FORMAT).ok()
398}
399
400fn append_to_file(path: &Path, content: &str) {
401    use std::fs::OpenOptions;
402    use std::io::Write;
403    match OpenOptions::new().create(true).append(true).open(path) {
404        Ok(mut f) => {
405            if let Err(e) = f.write_all(content.as_bytes()) {
406                error!("❌ 写入文件失败: {}", e);
407            }
408        }
409        Err(e) => error!("❌ 打开文件失败: {}", e),
410    }
411}
412
413// ========== open 命令 ==========
414
415/// 处理 reportctl open 命令:用内置 TUI 编辑器打开日报文件,自由编辑全文
416fn handle_open_report(config: &YamlConfig) {
417    let report_path = match get_report_path(config) {
418        Some(p) => p,
419        None => return,
420    };
421
422    let path = Path::new(&report_path);
423    if !path.is_file() {
424        error!("❌ 日报文件不存在: {}", report_path);
425        return;
426    }
427
428    // 读取文件全部内容
429    let content = match fs::read_to_string(path) {
430        Ok(c) => c,
431        Err(e) => {
432            error!("❌ 读取日报文件失败: {}", e);
433            return;
434        }
435    };
436
437    let lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
438
439    // 用 TUI 编辑器打开全文(NORMAL 模式)
440    match crate::tui::editor::open_multiline_editor_with_content("📝 编辑日报文件", &lines) {
441        Ok(Some(text)) => {
442            // 用户提交了内容,整体回写文件
443            let mut result = text;
444            if !result.ends_with('\n') {
445                result.push('\n');
446            }
447            if let Err(e) = fs::write(path, &result) {
448                error!("❌ 写入日报文件失败: {}", e);
449                return;
450            }
451            info!("✅ 日报文件已保存:{}", report_path);
452        }
453        Ok(None) => {
454            info!("已取消编辑,文件未修改");
455        }
456        Err(e) => {
457            error!("❌ 编辑器启动失败: {}", e);
458        }
459    }
460}
461
462// ========== set-url 命令 ==========
463
464/// 处理 reportctl set-url 命令:设置 git 仓库地址
465fn handle_set_url(url: Option<&str>, config: &mut YamlConfig) {
466    match url {
467        Some(u) if !u.is_empty() => {
468            let old = config.get_property(section::REPORT, config_key::GIT_REPO).cloned();
469            config.set_property(section::REPORT, config_key::GIT_REPO, u);
470
471            // 如果日报目录已有 .git,同步更新 remote origin
472            if let Some(dir) = get_report_dir(config) {
473                let git_dir = Path::new(&dir).join(".git");
474                if git_dir.exists() {
475                    sync_git_remote(config);
476                }
477            }
478
479            match old {
480                Some(old_url) if !old_url.is_empty() => {
481                    info!("✅ git 仓库地址已更新: {} → {}", old_url, u);
482                }
483                _ => {
484                    info!("✅ git 仓库地址已设置: {}", u);
485                }
486            }
487        }
488        _ => {
489            // 无参数时显示当前配置
490            match config.get_property(section::REPORT, config_key::GIT_REPO) {
491                Some(url) if !url.is_empty() => {
492                    info!("📦 当前 git 仓库地址: {}", url);
493                }
494                _ => {
495                    info!("📦 尚未配置 git 仓库地址");
496                    usage!("reportctl set-url <repo_url>");
497                }
498            }
499        }
500    }
501}
502
503// ========== push / pull 命令 ==========
504
505/// 获取日报目录(report 文件所在的目录)
506fn get_report_dir(config: &YamlConfig) -> Option<String> {
507    let report_path = config.report_file_path();
508    report_path.parent().map(|p| p.to_string_lossy().to_string())
509}
510
511/// 在日报目录下执行 git 命令
512fn run_git_in_report_dir(args: &[&str], config: &YamlConfig) -> Option<std::process::ExitStatus> {
513    let dir = match get_report_dir(config) {
514        Some(d) => d,
515        None => {
516            error!("❌ 无法确定日报目录");
517            return None;
518        }
519    };
520
521    let result = Command::new("git")
522        .args(args)
523        .current_dir(&dir)
524        .status();
525
526    match result {
527        Ok(status) => Some(status),
528        Err(e) => {
529            error!("💥 执行 git 命令失败: {}", e);
530            None
531        }
532    }
533}
534
535/// 检查日报目录是否已初始化 git 仓库,如果没有则初始化并配置 remote
536fn ensure_git_repo(config: &YamlConfig) -> bool {
537    let dir = match get_report_dir(config) {
538        Some(d) => d,
539        None => {
540            error!("❌ 无法确定日报目录");
541            return false;
542        }
543    };
544
545    let git_dir = Path::new(&dir).join(".git");
546    if git_dir.exists() {
547        // 已初始化,同步 remote URL(防止 set-url 后 remote 不一致)
548        sync_git_remote(config);
549        return true;
550    }
551
552    // 检查是否有配置 git_repo
553    let git_repo = config.get_property(section::REPORT, config_key::GIT_REPO);
554    if git_repo.is_none() || git_repo.unwrap().is_empty() {
555        error!("❌ 尚未配置 git 仓库地址,请先执行: j reportctl set-url <repo_url>");
556        return false;
557    }
558    let repo_url = git_repo.unwrap().clone();
559
560    info!("📦 日报目录尚未初始化 git 仓库,正在初始化...");
561
562    // git init -b main
563    if let Some(status) = run_git_in_report_dir(&["init", "-b", "main"], config) {
564        if !status.success() {
565            error!("❌ git init 失败");
566            return false;
567        }
568    } else {
569        return false;
570    }
571
572    // git remote add origin <repo_url>
573    if let Some(status) = run_git_in_report_dir(&["remote", "add", "origin", &repo_url], config) {
574        if !status.success() {
575            error!("❌ git remote add 失败");
576            return false;
577        }
578    } else {
579        return false;
580    }
581
582    info!("✅ git 仓库初始化完成,remote: {}", repo_url);
583    true
584}
585
586/// 同步 git remote origin URL 与配置文件中的 git_repo 保持一致
587fn sync_git_remote(config: &YamlConfig) {
588    let git_repo = match config.get_property(section::REPORT, config_key::GIT_REPO) {
589        Some(url) if !url.is_empty() => url.clone(),
590        _ => return, // 没有配置就不同步
591    };
592
593    // 获取当前 remote origin url
594    let dir = match get_report_dir(config) {
595        Some(d) => d,
596        None => return,
597    };
598
599    let current_url = Command::new("git")
600        .args(["remote", "get-url", "origin"])
601        .current_dir(&dir)
602        .output();
603
604    match current_url {
605        Ok(output) if output.status.success() => {
606            let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
607            if url != git_repo {
608                // URL 不一致,更新 remote
609                let _ = run_git_in_report_dir(&["remote", "set-url", "origin", &git_repo], config);
610                info!("🔄 已同步 remote origin: {} → {}", url, git_repo);
611            }
612        }
613        _ => {
614            // 没有 origin remote,添加一个
615            let _ = run_git_in_report_dir(&["remote", "add", "origin", &git_repo], config);
616        }
617    }
618}
619
620/// 处理 reportctl push 命令:推送周报到远程仓库
621fn handle_push(commit_msg: Option<&str>, config: &YamlConfig) {
622    // 检查 git_repo 配置
623    let git_repo = config.get_property(section::REPORT, config_key::GIT_REPO);
624    if git_repo.is_none() || git_repo.unwrap().is_empty() {
625        error!("❌ 尚未配置 git 仓库地址,请先执行: j reportctl set-url <repo_url>");
626        return;
627    }
628
629    // 确保 git 仓库已初始化
630    if !ensure_git_repo(config) {
631        return;
632    }
633
634    let default_msg = format!("update report {}", Local::now().format("%Y-%m-%d %H:%M"));
635    let msg = commit_msg.unwrap_or(&default_msg);
636
637    info!("📤 正在推送周报到远程仓库...");
638
639    // git add .
640    if let Some(status) = run_git_in_report_dir(&["add", "."], config) {
641        if !status.success() {
642            error!("❌ git add 失败");
643            return;
644        }
645    } else {
646        return;
647    }
648
649    // git commit -m "<msg>"
650    if let Some(status) = run_git_in_report_dir(&["commit", "-m", msg], config) {
651        if !status.success() {
652            // commit 可能因为没有变更而失败,这不一定是错误
653            info!("ℹ️ git commit 返回非零退出码(可能没有新变更)");
654        }
655    } else {
656        return;
657    }
658
659    // git push origin main
660    if let Some(status) = run_git_in_report_dir(&["push", "-u", "origin", "main"], config) {
661        if status.success() {
662            info!("✅ 周报已成功推送到远程仓库");
663        } else {
664            error!("❌ git push 失败,请检查网络连接和仓库权限");
665        }
666    }
667}
668
669/// 处理 reportctl pull 命令:从远程仓库拉取周报
670fn handle_pull(config: &YamlConfig) {
671    // 检查 git_repo 配置
672    let git_repo = config.get_property(section::REPORT, config_key::GIT_REPO);
673    if git_repo.is_none() || git_repo.unwrap().is_empty() {
674        error!("❌ 尚未配置 git 仓库地址,请先执行: j reportctl set-url <repo_url>");
675        return;
676    }
677
678    let dir = match get_report_dir(config) {
679        Some(d) => d,
680        None => {
681            error!("❌ 无法确定日报目录");
682            return;
683        }
684    };
685
686    let git_dir = Path::new(&dir).join(".git");
687
688    if !git_dir.exists() {
689        // 日报目录不是 git 仓库,尝试 clone
690        let repo_url = git_repo.unwrap().clone();
691        info!("📥 日报目录尚未初始化,正在从远程仓库克隆...");
692
693        // 先备份已有文件(如果有的话)
694        let report_path = config.report_file_path();
695        let has_existing = report_path.exists() && fs::metadata(&report_path).map(|m| m.len() > 0).unwrap_or(false);
696
697        if has_existing {
698            // 备份现有文件
699            let backup_path = report_path.with_extension("md.bak");
700            if let Err(e) = fs::copy(&report_path, &backup_path) {
701                error!("⚠️ 备份现有日报文件失败: {}", e);
702            } else {
703                info!("📋 已备份现有日报到: {:?}", backup_path);
704            }
705        }
706
707        // 清空目录内容后 clone
708        // 使用 git clone 到一个临时目录再移动
709        let temp_dir = Path::new(&dir).with_file_name(".report_clone_tmp");
710        let _ = fs::remove_dir_all(&temp_dir);
711
712        let result = Command::new("git")
713            .args(["clone", "-b", "main", &repo_url, &temp_dir.to_string_lossy()])
714            .status();
715
716        match result {
717            Ok(status) if status.success() => {
718                // 将 clone 出来的内容移到 report 目录
719                let _ = fs::remove_dir_all(&dir);
720                if let Err(e) = fs::rename(&temp_dir, &dir) {
721                    error!("❌ 移动克隆仓库失败: {},临时目录: {:?}", e, temp_dir);
722                    return;
723                }
724                info!("✅ 成功从远程仓库克隆周报");
725            }
726            Ok(_) => {
727                error!("❌ git clone 失败,请检查仓库地址和网络连接");
728                let _ = fs::remove_dir_all(&temp_dir);
729            }
730            Err(e) => {
731                error!("💥 执行 git clone 失败: {}", e);
732                let _ = fs::remove_dir_all(&temp_dir);
733            }
734        }
735    } else {
736        // 已经是 git 仓库,先同步 remote URL
737        sync_git_remote(config);
738
739        // 检测是否是空仓库(unborn branch,没有任何 commit)
740        let has_commits = Command::new("git")
741            .args(["rev-parse", "HEAD"])
742            .current_dir(&dir)
743            .output()
744            .map(|o| o.status.success())
745            .unwrap_or(false);
746
747        if !has_commits {
748            // 空仓库(git init 后未 commit),通过 fetch + checkout 来拉取
749            info!("📥 本地仓库尚无提交,正在从远程仓库拉取...");
750
751            // 备份本地已有的未跟踪文件
752            let report_path = config.report_file_path();
753            if report_path.exists() && fs::metadata(&report_path).map(|m| m.len() > 0).unwrap_or(false) {
754                let backup_path = report_path.with_extension("md.bak");
755                let _ = fs::copy(&report_path, &backup_path);
756                info!("📋 已备份本地日报到: {:?}", backup_path);
757            }
758
759            // git fetch origin main
760            if let Some(status) = run_git_in_report_dir(&["fetch", "origin", "main"], config) {
761                if !status.success() {
762                    error!("❌ git fetch 失败,请检查网络连接和仓库地址");
763                    return;
764                }
765            } else {
766                return;
767            }
768
769            // git reset --hard origin/main(强制用远程覆盖本地)
770            if let Some(status) = run_git_in_report_dir(&["reset", "--hard", "origin/main"], config) {
771                if status.success() {
772                    info!("✅ 成功从远程仓库拉取周报");
773                } else {
774                    error!("❌ git reset 失败");
775                }
776            }
777        } else {
778            // 正常仓库,先 stash 再 pull
779            info!("📥 正在从远程仓库拉取最新周报...");
780
781            // 先暂存本地未跟踪/修改的文件,防止 pull 时冲突
782            let _ = run_git_in_report_dir(&["add", "-A"], config);
783            let stash_result = Command::new("git")
784                .args(["stash", "push", "-m", "auto-stash-before-pull"])
785                .current_dir(&dir)
786                .output();
787            let has_stash = match &stash_result {
788                Ok(output) => {
789                    let msg = String::from_utf8_lossy(&output.stdout);
790                    !msg.contains("No local changes")
791                }
792                Err(_) => false,
793            };
794
795            // 执行 pull
796            let pull_ok = if let Some(status) = run_git_in_report_dir(&["pull", "origin", "main", "--rebase"], config) {
797                if status.success() {
798                    info!("✅ 周报已更新到最新版本");
799                    true
800                } else {
801                    error!("❌ git pull 失败,请检查网络连接或手动解决冲突");
802                    false
803                }
804            } else {
805                false
806            };
807
808            // 恢复 stash
809            if has_stash {
810                if let Some(status) = run_git_in_report_dir(&["stash", "pop"], config) {
811                    if !status.success() && pull_ok {
812                        info!("⚠️ stash pop 存在冲突,请手动合并本地修改(已保存在 git stash 中)");
813                    }
814                }
815            }
816        }
817    }
818}
819
820// ========== check 命令 ==========
821
822/// 处理 check 命令: j check [line_count]
823pub fn handle_check(line_count: Option<&str>, config: &YamlConfig) {
824    // 检查是否是 open 子命令
825    if line_count == Some("open") {
826        handle_open_report(config);
827        return;
828    }
829
830    let num = match line_count {
831        Some(s) => match s.parse::<usize>() {
832            Ok(n) if n > 0 => n,
833            _ => {
834                error!("❌ 无效的行数参数: {},请输入正整数或 open", s);
835                return;
836            }
837        },
838        None => DEFAULT_CHECK_LINES,
839    };
840
841    let report_path = match get_report_path(config) {
842        Some(p) => p,
843        None => return,
844    };
845
846    info!("📂 正在读取周报文件路径: {}", report_path);
847
848    let path = Path::new(&report_path);
849    if !path.is_file() {
850        error!("❌ 文件不存在或不是有效文件: {}", report_path);
851        return;
852    }
853
854    let lines = read_last_n_lines(path, num);
855    info!("📄 最近的 {} 行内容如下:", lines.len());
856    // 周报本身就是 Markdown 格式,使用 termimad 渲染
857    let md_content = lines.join("\n");
858    crate::md!("{}", md_content);
859}
860
861// ========== search 命令 ==========
862
863/// 处理 search 命令: j search <line_count|all> <target> [-f|-fuzzy]
864pub fn handle_search(line_count: &str, target: &str, fuzzy_flag: Option<&str>, config: &YamlConfig) {
865    let num = if line_count == "all" {
866        usize::MAX
867    } else {
868        match line_count.parse::<usize>() {
869            Ok(n) if n > 0 => n,
870            _ => {
871                error!("❌ 无效的行数参数: {},请输入正整数或 all", line_count);
872                return;
873            }
874        }
875    };
876
877    let report_path = match get_report_path(config) {
878        Some(p) => p,
879        None => return,
880    };
881
882    info!("📂 正在读取周报文件路径: {}", report_path);
883
884    let path = Path::new(&report_path);
885    if !path.is_file() {
886        error!("❌ 文件不存在或不是有效文件: {}", report_path);
887        return;
888    }
889
890    let is_fuzzy = matches!(fuzzy_flag, Some(f) if f == search_flag::FUZZY_SHORT || f == search_flag::FUZZY);
891    if is_fuzzy {
892        info!("启用模糊匹配...");
893    }
894
895    let lines = read_last_n_lines(path, num);
896    info!("🔍 搜索目标关键字: {}", target.green());
897
898    let mut index = 0;
899    for line in &lines {
900        let matched = if is_fuzzy {
901            fuzzy::fuzzy_match(line, target)
902        } else {
903            line.contains(target)
904        };
905
906        if matched {
907            index += 1;
908            let highlighted = fuzzy::highlight_matches(line, target, is_fuzzy);
909            info!("[{}] {}", index, highlighted);
910        }
911    }
912
913    if index == 0 {
914        info!("nothing found 😢");
915    }
916}
917
918/// 从文件尾部读取最后 N 行(高效实现,不需要读取整个文件)
919fn read_last_n_lines(path: &Path, n: usize) -> Vec<String> {
920    let mut lines = Vec::new();
921    let buffer_size: usize = 16384; // 16KB
922
923    let mut file = match fs::File::open(path) {
924        Ok(f) => f,
925        Err(e) => {
926            error!("❌ 读取文件时发生错误: {}", e);
927            return lines;
928        }
929    };
930
931    let file_len = match file.metadata() {
932        Ok(m) => m.len() as usize,
933        Err(_) => return lines,
934    };
935
936    if file_len == 0 {
937        return lines;
938    }
939
940    // 对于较小的文件或者需要读取全部内容的情况,直接全部读取
941    if n == usize::MAX || file_len <= buffer_size * 2 {
942        let mut content = String::new();
943        let _ = file.seek(SeekFrom::Start(0));
944        if file.read_to_string(&mut content).is_ok() {
945            let all_lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
946            if n >= all_lines.len() {
947                return all_lines;
948            }
949            return all_lines[all_lines.len() - n..].to_vec();
950        }
951        return lines;
952    }
953
954    // 从文件尾部逐块读取
955    let mut pointer = file_len;
956    let mut remainder = Vec::new();
957
958    while pointer > 0 && lines.len() < n {
959        let bytes_to_read = pointer.min(buffer_size);
960        pointer -= bytes_to_read;
961
962        let _ = file.seek(SeekFrom::Start(pointer as u64));
963        let mut buffer = vec![0u8; bytes_to_read];
964        if file.read_exact(&mut buffer).is_err() {
965            break;
966        }
967
968        // 将 remainder(上次剩余的不完整行)追加到这个块的末尾
969        buffer.extend(remainder.drain(..));
970
971        // 从后向前按行分割
972        let text = String::from_utf8_lossy(&buffer).to_string();
973        let mut block_lines: Vec<&str> = text.split('\n').collect();
974
975        // 第一行可能是不完整的(跨块)
976        if pointer > 0 {
977            remainder = block_lines.remove(0).as_bytes().to_vec();
978        }
979
980        for line in block_lines.into_iter().rev() {
981            if !line.is_empty() {
982                lines.push(line.to_string());
983                if lines.len() >= n {
984                    break;
985                }
986            }
987        }
988    }
989
990    // 处理文件最开头的那行
991    if !remainder.is_empty() && lines.len() < n {
992        let line = String::from_utf8_lossy(&remainder).to_string();
993        if !line.is_empty() {
994            lines.push(line);
995        }
996    }
997
998    lines.reverse();
999    lines
1000}