Skip to main content

j_cli/command/
report.rs

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