1use anyhow::{bail, Context, Result};
13use rusqlite::Connection;
14use serde::Serialize;
15use std::io::{self, Write};
16use std::path::{Path, PathBuf};
17
18use crate::cli::SessionFormat;
19use crate::db::{self, CommandRecord, ProjectSummary};
20use crate::gitctx;
21use crate::mdfmt::{
22 display_home, md_code_block, md_table_cell_code, md_table_cell_text, opt, short_datetime,
23};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ProjectSource {
28 Git,
30 Marker(&'static str),
32 Directory,
34}
35
36impl ProjectSource {
37 pub fn label(&self) -> String {
39 match self {
40 ProjectSource::Git => "dépôt git".to_string(),
41 ProjectSource::Marker(f) => format!("marqueur {f}"),
42 ProjectSource::Directory => "dossier courant".to_string(),
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
49pub struct ProjectInfo {
50 pub name: String,
52 pub root: Option<PathBuf>,
54 pub source: ProjectSource,
56}
57
58const MARKERS: &[&str] = &[
60 "Cargo.toml",
61 "package.json",
62 "pyproject.toml",
63 "go.mod",
64 "composer.json",
65];
66
67pub fn detect(cwd: &Path) -> ProjectInfo {
70 let git = gitctx::detect(cwd);
72 if let Some(root) = git.root {
73 let path = PathBuf::from(&root);
74 return ProjectInfo {
75 name: base_name(&path).unwrap_or(root),
76 root: Some(path),
77 source: ProjectSource::Git,
78 };
79 }
80
81 if let Some((dir, marker)) = find_marker(cwd) {
83 return ProjectInfo {
84 name: base_name(&dir).unwrap_or_else(|| dir.display().to_string()),
85 root: Some(dir),
86 source: ProjectSource::Marker(marker),
87 };
88 }
89
90 ProjectInfo {
92 name: base_name(cwd).unwrap_or_else(|| "(inconnu)".to_string()),
93 root: Some(cwd.to_path_buf()),
94 source: ProjectSource::Directory,
95 }
96}
97
98pub fn current_name() -> Option<String> {
100 let cwd = std::env::current_dir().ok()?;
101 Some(detect(&cwd).name)
102}
103
104fn find_marker(start: &Path) -> Option<(PathBuf, &'static str)> {
106 let mut dir = Some(start);
107 while let Some(d) = dir {
108 for marker in MARKERS {
109 if d.join(marker).is_file() {
110 return Some((d.to_path_buf(), marker));
111 }
112 }
113 dir = d.parent();
114 }
115 None
116}
117
118fn base_name(path: &Path) -> Option<String> {
120 path.file_name()
121 .and_then(|n| n.to_str())
122 .map(|s| s.to_string())
123}
124
125pub fn run_current() -> Result<()> {
127 let cwd = std::env::current_dir()?;
128 let info = detect(&cwd);
129 println!("Projet : {}", info.name);
130 if let Some(root) = &info.root {
131 println!("Racine : {}", root.display());
132 }
133 println!("Source : {}", info.source.label());
134 Ok(())
135}
136
137pub fn run_list(limit: Option<usize>, json: bool) -> Result<()> {
139 let conn = db::open(&crate::config::db_path()?)?;
140 let projects = db::project_summaries(&conn, limit)?;
141
142 if json {
143 let rows: Vec<ProjectListJson> = projects.iter().map(ProjectListJson::from).collect();
144 println!("{}", serde_json::to_string_pretty(&rows)?);
145 return Ok(());
146 }
147
148 if projects.is_empty() {
149 println!("Aucun projet Git enregistré dans l'historique.");
150 return Ok(());
151 }
152
153 let stdout = io::stdout();
154 let mut out = stdout.lock();
155 writeln!(out, "Projets connus ({})", projects.len())?;
156 writeln!(out)?;
157 writeln!(
158 out,
159 "{:<24} {:>9} {:>8} {:<16} BRANCHES",
160 "PROJET", "COMMANDES", "SESSIONS", "DERNIÈRE ACTIVITÉ"
161 )?;
162 for p in &projects {
163 writeln!(
164 out,
165 "{:<24} {:>9} {:>8} {:<16} {}",
166 short_name(&p.root),
167 p.command_count,
168 p.session_count,
169 short_datetime(&p.last_activity),
170 branches_label(&p.branches)
171 )?;
172 }
173 Ok(())
174}
175
176pub fn run_show(
178 project: Option<String>,
179 current: bool,
180 limit: Option<usize>,
181 json: bool,
182) -> Result<()> {
183 let conn = db::open(&crate::config::db_path()?)?;
184 let root = resolve_root(&conn, project, current)?;
185 let summary = db::project_summary(&conn, &root)?
186 .with_context(|| format!("projet absent de l'historique : {}", display_home(&root)))?;
187
188 let recent_limit = limit.unwrap_or(20);
189 let recent = db::project_records(&conn, &root, None, None, false, Some(recent_limit))?;
190 let failures = db::project_records(&conn, &root, None, None, true, Some(recent_limit))?;
191
192 if json {
193 let doc = ProjectShowJson {
194 project: ProjectMetaJson::from(&summary),
195 recent: recent.iter().map(RecordJson::from).collect(),
196 recent_failures: failures.iter().map(RecordJson::from).collect(),
197 };
198 println!("{}", serde_json::to_string_pretty(&doc)?);
199 return Ok(());
200 }
201
202 let stdout = io::stdout();
203 let mut out = stdout.lock();
204 writeln!(out, "Projet : {}", short_name(&summary.root))?;
205 writeln!(out, "Racine : {}", display_home(&summary.root))?;
206 writeln!(out, "Remote : {}", opt(&summary.remote))?;
207 writeln!(out, "Commandes : {}", summary.command_count)?;
208 writeln!(out, "Sessions : {}", summary.session_count)?;
209 writeln!(
210 out,
211 "Activité : {} → {}",
212 short_datetime(&summary.first_activity),
213 short_datetime(&summary.last_activity)
214 )?;
215 writeln!(out, "Branches : {}", branches_label(&summary.branches))?;
216
217 writeln!(out)?;
218 writeln!(out, "Commandes récentes ({})", recent.len())?;
219 for c in &recent {
220 write_command_line(&mut out, c)?;
221 }
222
223 if !failures.is_empty() {
224 writeln!(out)?;
225 writeln!(out, "Derniers échecs ({})", failures.len())?;
226 for c in &failures {
227 write_command_line(&mut out, c)?;
228 }
229 }
230 Ok(())
231}
232
233#[allow(clippy::too_many_arguments)]
235pub fn run_report(
236 project: Option<String>,
237 current: bool,
238 since: Option<String>,
239 until: Option<String>,
240 format: SessionFormat,
241 output: Option<PathBuf>,
242 force: bool,
243 limit: Option<usize>,
244) -> Result<()> {
245 let conn = db::open(&crate::config::db_path()?)?;
246 let root = resolve_root(&conn, project, current)?;
247 let summary = db::project_summary(&conn, &root)?
248 .with_context(|| format!("projet absent de l'historique : {}", display_home(&root)))?;
249
250 let since_ts = resolve_bound(since.as_deref(), db::resolve_since, "--since")?;
251 let before_ts = resolve_bound(until.as_deref(), db::resolve_before, "--until")?;
252
253 let records = db::project_records(
256 &conn,
257 &root,
258 since_ts.as_deref(),
259 before_ts.as_deref(),
260 false,
261 None,
262 )?;
263 let report = ReportData::build(
264 &summary,
265 &records,
266 since.as_deref(),
267 until.as_deref(),
268 limit,
269 );
270
271 let content = match format {
272 SessionFormat::Markdown => report.render_markdown(),
273 SessionFormat::Json => serde_json::to_string_pretty(&report.as_json())?,
274 };
275
276 match output {
277 Some(path) => {
278 if path.exists() && !force {
279 bail!(
280 "Le fichier {} existe déjà. Utilisez --force pour l'écraser.",
281 path.display()
282 );
283 }
284 std::fs::write(&path, content.as_bytes())
285 .with_context(|| format!("écriture du rapport {}", path.display()))?;
286 eprintln!(
287 "Rapport du projet {} écrit dans {} ({} commandes).",
288 short_name(&summary.root),
289 path.display(),
290 report.period_count
291 );
292 }
293 None => {
294 let stdout = io::stdout();
295 let mut out = stdout.lock();
296 out.write_all(content.as_bytes())?;
297 }
298 }
299 Ok(())
300}
301
302fn resolve_root(conn: &Connection, project: Option<String>, current: bool) -> Result<String> {
307 if current {
308 let cwd = std::env::current_dir().context("répertoire courant introuvable")?;
309 let info = detect(&cwd);
310 let root = info
311 .root
312 .and_then(|p| p.to_str().map(|s| s.to_string()))
313 .context("racine du projet courant indéterminée")?;
314 return Ok(root);
315 }
316
317 let needle = match project {
318 Some(p) => expand_tilde(&p),
319 None => bail!("Préciser un projet (racine ou nom) ou utiliser --current."),
320 };
321
322 let matches = db::match_project_roots(conn, &needle)?;
323 match matches.len() {
324 1 => Ok(matches.into_iter().next().unwrap()),
325 0 => bail!("Aucun projet ne correspond à « {needle} ». Voir `mnemo project list`."),
326 _ => {
327 let listed = matches
328 .iter()
329 .map(|r| display_home(r))
330 .collect::<Vec<_>>()
331 .join(", ");
332 bail!("Plusieurs projets correspondent à « {needle} » : {listed}. Préciser la racine complète.")
333 }
334 }
335}
336
337fn expand_tilde(path: &str) -> String {
339 if path == "~" {
340 if let Some(home) = dirs::home_dir() {
341 return home.to_string_lossy().into_owned();
342 }
343 } else if let Some(rest) = path.strip_prefix("~/") {
344 if let Some(home) = dirs::home_dir() {
345 return home.join(rest).to_string_lossy().into_owned();
346 }
347 }
348 path.to_string()
349}
350
351fn resolve_bound(
353 spec: Option<&str>,
354 resolver: fn(&str) -> Option<String>,
355 flag: &str,
356) -> Result<Option<String>> {
357 match spec {
358 None => Ok(None),
359 Some(s) => match resolver(s) {
360 Some(ts) => Ok(Some(ts)),
361 None => bail!("Valeur {flag} invalide : « {s} » (durée ou date AAAA-MM-JJ attendue)."),
362 },
363 }
364}
365
366fn short_name(root: &str) -> String {
368 base_name(Path::new(root)).unwrap_or_else(|| root.to_string())
369}
370
371fn branches_label(branches: &[String]) -> String {
373 if branches.is_empty() {
374 "-".to_string()
375 } else {
376 branches.join(", ")
377 }
378}
379
380fn write_command_line<W: Write>(out: &mut W, c: &CommandRecord) -> io::Result<()> {
382 match c.exit_code {
383 Some(code) if code != 0 => writeln!(
384 out,
385 "[{}] {} (exit {code})",
386 short_datetime(&c.created_at),
387 c.command
388 ),
389 _ => writeln!(out, "[{}] {}", short_datetime(&c.created_at), c.command),
390 }
391}
392
393struct ReportData<'a> {
395 summary: &'a ProjectSummary,
396 since_spec: Option<&'a str>,
397 until_spec: Option<&'a str>,
398 period_count: usize,
399 failure_count: usize,
400 session_count: usize,
401 period_branches: Vec<String>,
402 period_start: Option<String>,
403 period_end: Option<String>,
404 detail: Vec<&'a CommandRecord>,
406 failures: Vec<&'a CommandRecord>,
408}
409
410impl<'a> ReportData<'a> {
411 fn build(
414 summary: &'a ProjectSummary,
415 records: &'a [CommandRecord],
416 since_spec: Option<&'a str>,
417 until_spec: Option<&'a str>,
418 limit: Option<usize>,
419 ) -> Self {
420 let mut sessions = std::collections::BTreeSet::new();
421 let mut branches = std::collections::BTreeSet::new();
422 let mut failure_count = 0usize;
423 for c in records {
424 if let Some(sid) = c.session_id.as_deref().filter(|s| !s.trim().is_empty()) {
425 sessions.insert(sid.to_string());
426 }
427 if let Some(b) = c.git_branch.as_deref().filter(|s| !s.is_empty()) {
428 branches.insert(b.to_string());
429 }
430 if matches!(c.exit_code, Some(code) if code != 0) {
431 failure_count += 1;
432 }
433 }
434
435 let period_end = records.first().map(|c| c.created_at.clone());
437 let period_start = records.last().map(|c| c.created_at.clone());
438
439 let failures: Vec<&CommandRecord> = records
440 .iter()
441 .filter(|c| matches!(c.exit_code, Some(code) if code != 0))
442 .take(limit.unwrap_or(20))
443 .collect();
444
445 let mut detail: Vec<&CommandRecord> = match limit {
448 Some(n) => records.iter().take(n).collect(),
449 None => records.iter().collect(),
450 };
451 detail.reverse();
452
453 ReportData {
454 summary,
455 since_spec,
456 until_spec,
457 period_count: records.len(),
458 failure_count,
459 session_count: sessions.len(),
460 period_branches: branches.into_iter().collect(),
461 period_start,
462 period_end,
463 detail,
464 failures,
465 }
466 }
467
468 fn render_markdown(&self) -> String {
470 let mut out = String::new();
471 out.push_str(&format!(
472 "# Rapport projet — {}\n\n",
473 short_name(&self.summary.root)
474 ));
475
476 out.push_str(&format!(
477 "- Racine : {}\n",
478 display_home(&self.summary.root)
479 ));
480 out.push_str(&format!("- Remote : {}\n", opt(&self.summary.remote)));
481 out.push_str(&format!(
482 "- Période : {} → {}\n",
483 self.since_spec.unwrap_or("(début)"),
484 self.until_spec.unwrap_or("(maintenant)")
485 ));
486 out.push_str(&format!("- Commandes : {}\n", self.period_count));
487 out.push_str(&format!("- Échecs : {}\n", self.failure_count));
488 out.push_str(&format!("- Sessions : {}\n", self.session_count));
489 let start = self
490 .period_start
491 .as_deref()
492 .map(short_datetime)
493 .unwrap_or("-");
494 let end = self
495 .period_end
496 .as_deref()
497 .map(short_datetime)
498 .unwrap_or("-");
499 out.push_str(&format!("- Première activité : {start}\n"));
500 out.push_str(&format!("- Dernière activité : {end}\n"));
501 out.push_str(&format!(
502 "- Branches : {}\n",
503 branches_label(&self.period_branches)
504 ));
505 out.push('\n');
506
507 out.push_str("## Commandes\n\n");
508 if self.detail.is_empty() {
509 out.push_str("_Aucune commande sur la période._\n\n");
510 } else {
511 let commands: Vec<String> = self.detail.iter().map(|c| c.command.clone()).collect();
512 out.push_str(&md_code_block(&commands));
513 out.push('\n');
514 }
515
516 out.push_str("## Détail chronologique\n\n");
517 out.push_str("| Date | Code retour | Branche | Commande |\n");
518 out.push_str("| --- | ---: | --- | --- |\n");
519 for c in &self.detail {
520 let code = c
521 .exit_code
522 .map(|c| c.to_string())
523 .unwrap_or_else(|| "-".to_string());
524 let branch = c
525 .git_branch
526 .as_deref()
527 .filter(|s| !s.is_empty())
528 .unwrap_or("-");
529 out.push_str(&format!(
530 "| {} | {} | {} | {} |\n",
531 short_datetime(&c.created_at),
532 code,
533 md_table_cell_text(branch),
534 md_table_cell_code(&c.command)
535 ));
536 }
537
538 if !self.failures.is_empty() {
539 out.push('\n');
540 out.push_str("## Échecs\n\n");
541 out.push_str("| Date | Code retour | Commande |\n");
542 out.push_str("| --- | ---: | --- |\n");
543 for c in &self.failures {
544 let code = c
545 .exit_code
546 .map(|c| c.to_string())
547 .unwrap_or_else(|| "-".to_string());
548 out.push_str(&format!(
549 "| {} | {} | {} |\n",
550 short_datetime(&c.created_at),
551 code,
552 md_table_cell_code(&c.command)
553 ));
554 }
555 }
556 out
557 }
558
559 fn as_json(&self) -> ReportJson<'_> {
561 ReportJson {
562 project: ProjectMetaJson::from(self.summary),
563 period: PeriodJson {
564 since: self.since_spec,
565 until: self.until_spec,
566 command_count: self.period_count,
567 failure_count: self.failure_count,
568 session_count: self.session_count,
569 first_activity: self.period_start.clone(),
570 last_activity: self.period_end.clone(),
571 branches: self.period_branches.clone(),
572 },
573 commands: self.detail.iter().map(|c| RecordJson::from(*c)).collect(),
574 failures: self.failures.iter().map(|c| RecordJson::from(*c)).collect(),
575 }
576 }
577}
578
579#[derive(Serialize)]
581struct ProjectListJson {
582 name: String,
583 root: String,
584 command_count: i64,
585 session_count: i64,
586 last_activity: String,
587 branches: Vec<String>,
588}
589
590impl From<&ProjectSummary> for ProjectListJson {
591 fn from(s: &ProjectSummary) -> Self {
592 ProjectListJson {
593 name: short_name(&s.root),
594 root: s.root.clone(),
595 command_count: s.command_count,
596 session_count: s.session_count,
597 last_activity: s.last_activity.clone(),
598 branches: s.branches.clone(),
599 }
600 }
601}
602
603#[derive(Serialize)]
605struct ProjectMetaJson {
606 name: String,
607 root: String,
608 remote: Option<String>,
609 command_count: i64,
610 session_count: i64,
611 first_activity: String,
612 last_activity: String,
613 branches: Vec<String>,
614}
615
616impl From<&ProjectSummary> for ProjectMetaJson {
617 fn from(s: &ProjectSummary) -> Self {
618 ProjectMetaJson {
619 name: short_name(&s.root),
620 root: s.root.clone(),
621 remote: s.remote.clone(),
622 command_count: s.command_count,
623 session_count: s.session_count,
624 first_activity: s.first_activity.clone(),
625 last_activity: s.last_activity.clone(),
626 branches: s.branches.clone(),
627 }
628 }
629}
630
631#[derive(Serialize)]
633struct RecordJson<'a> {
634 created_at: &'a str,
635 exit_code: Option<i64>,
636 git_branch: Option<&'a str>,
637 command: &'a str,
638}
639
640impl<'a> From<&'a CommandRecord> for RecordJson<'a> {
641 fn from(c: &'a CommandRecord) -> Self {
642 RecordJson {
643 created_at: &c.created_at,
644 exit_code: c.exit_code,
645 git_branch: c.git_branch.as_deref(),
646 command: &c.command,
647 }
648 }
649}
650
651#[derive(Serialize)]
653struct ProjectShowJson<'a> {
654 project: ProjectMetaJson,
655 recent: Vec<RecordJson<'a>>,
656 recent_failures: Vec<RecordJson<'a>>,
657}
658
659#[derive(Serialize)]
661struct ReportJson<'a> {
662 project: ProjectMetaJson,
663 period: PeriodJson<'a>,
664 commands: Vec<RecordJson<'a>>,
665 failures: Vec<RecordJson<'a>>,
666}
667
668#[derive(Serialize)]
670struct PeriodJson<'a> {
671 since: Option<&'a str>,
672 until: Option<&'a str>,
673 command_count: usize,
674 failure_count: usize,
675 session_count: usize,
676 first_activity: Option<String>,
677 last_activity: Option<String>,
678 branches: Vec<String>,
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684 use std::fs;
685
686 #[test]
687 fn detecte_un_marqueur_cargo() {
688 let dir = tempfile::tempdir().unwrap();
689 let root = dir.path();
690 fs::write(root.join("Cargo.toml"), "[package]\n").unwrap();
691 let sub = root.join("src/inner");
692 fs::create_dir_all(&sub).unwrap();
693
694 let info = detect(&sub);
695 assert!(matches!(info.source, ProjectSource::Marker("Cargo.toml")));
696 assert_eq!(info.root.as_deref(), Some(root));
697 }
698
699 #[test]
700 fn retombe_sur_le_nom_du_dossier() {
701 let dir = tempfile::tempdir().unwrap();
702 let sub = dir.path().join("projet-sans-marqueur");
703 fs::create_dir_all(&sub).unwrap();
704
705 let info = detect(&sub);
706 assert_eq!(info.source, ProjectSource::Directory);
707 assert_eq!(info.name, "projet-sans-marqueur");
708 }
709
710 #[test]
711 fn summaries_agregent_sessions_et_branches() {
712 let conn = db::open_in_memory().unwrap();
713 let rows = [
714 (
715 "a",
716 "/home/u/proj-a",
717 "main",
718 Some("s1"),
719 "2026-06-14 10:00:00",
720 0,
721 ),
722 (
723 "b",
724 "/home/u/proj-a",
725 "feat",
726 Some("s1"),
727 "2026-06-14 10:05:00",
728 1,
729 ),
730 (
731 "c",
732 "/home/u/proj-a",
733 "main",
734 Some("s2"),
735 "2026-06-15 09:00:00",
736 0,
737 ),
738 (
739 "d",
740 "/home/u/proj-b",
741 "main",
742 None,
743 "2026-06-13 08:00:00",
744 0,
745 ),
746 ];
747 for (cmd, root, branch, sid, at, code) in rows {
748 db::insert_command(
749 &conn,
750 &db::NewCommand {
751 command: cmd.into(),
752 cwd: Some(root.into()),
753 shell: Some("bash".into()),
754 hostname: Some("h".into()),
755 exit_code: Some(code),
756 created_at: at.into(),
757 git_root: Some(root.into()),
758 git_branch: Some(branch.into()),
759 git_remote: None,
760 session_id: sid.map(|s| s.to_string()),
761 },
762 )
763 .unwrap();
764 }
765
766 let summary = db::project_summary(&conn, "/home/u/proj-a")
767 .unwrap()
768 .unwrap();
769 assert_eq!(summary.command_count, 3);
770 assert_eq!(summary.session_count, 2);
771 assert_eq!(
772 summary.branches,
773 vec!["feat".to_string(), "main".to_string()]
774 );
775 assert_eq!(summary.last_activity, "2026-06-15 09:00:00");
776
777 let all = db::project_summaries(&conn, None).unwrap();
779 assert_eq!(all.len(), 2);
780 assert_eq!(short_name(&all[0].root), "proj-a");
781 }
782
783 #[test]
784 fn resolve_bound_echoue_sur_spec_invalide() {
785 assert!(resolve_bound(Some("pas-une-date"), db::resolve_since, "--since").is_err());
786 assert!(resolve_bound(None, db::resolve_since, "--since")
787 .unwrap()
788 .is_none());
789 assert!(resolve_bound(Some("7d"), db::resolve_since, "--since")
790 .unwrap()
791 .is_some());
792 }
793
794 #[test]
795 fn branches_label_compacte() {
796 assert_eq!(branches_label(&[]), "-");
797 assert_eq!(
798 branches_label(&["main".to_string(), "dev".to_string()]),
799 "main, dev"
800 );
801 }
802}