Skip to main content

mnemo/
project.rs

1//! Détection du « projet » courant et inventaire des projets connus.
2//!
3//! Stratégie de détection (du plus fiable au plus approximatif) :
4//! 1. **Racine Git** (`git rev-parse --show-toplevel`), priorité absolue ;
5//! 2. **Fichier marqueur** (`package.json`, `Cargo.toml`, `pyproject.toml`,
6//!    `go.mod`, `composer.json`) trouvé en remontant l'arborescence ;
7//! 3. **Nom du dossier courant** en dernier recours.
8//!
9//! Cette logique n'altère jamais le champ historique `git_root` : elle ne sert
10//! qu'à *résoudre* un nom de projet pour les filtres et l'affichage.
11
12use 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/// Origine de la détection d'un projet.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ProjectSource {
28    /// Détecté via la racine d'un dépôt Git.
29    Git,
30    /// Détecté via un fichier marqueur d'écosystème.
31    Marker(&'static str),
32    /// Détecté via le simple nom du dossier courant.
33    Directory,
34}
35
36impl ProjectSource {
37    /// Libellé lisible de la source.
38    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/// Projet détecté pour un répertoire de travail.
48#[derive(Debug, Clone)]
49pub struct ProjectInfo {
50    /// Nom court du projet (dernier segment de la racine).
51    pub name: String,
52    /// Racine du projet si connue (racine git ou dossier du marqueur).
53    pub root: Option<PathBuf>,
54    /// Comment le projet a été détecté.
55    pub source: ProjectSource,
56}
57
58/// Fichiers marqueurs reconnus, par ordre de priorité.
59const MARKERS: &[&str] = &[
60    "Cargo.toml",
61    "package.json",
62    "pyproject.toml",
63    "go.mod",
64    "composer.json",
65];
66
67/// Détecte le projet associé à `cwd`. Ne renvoie jamais d'erreur : au pire on
68/// retombe sur le nom du dossier courant.
69pub fn detect(cwd: &Path) -> ProjectInfo {
70    // 1. Racine Git prioritaire.
71    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    // 2. Fichier marqueur en remontant l'arborescence.
82    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    // 3. Nom du dossier courant.
91    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
98/// Nom du projet courant (résolution de `--project current`).
99pub fn current_name() -> Option<String> {
100    let cwd = std::env::current_dir().ok()?;
101    Some(detect(&cwd).name)
102}
103
104/// Cherche le premier dossier marqueur en remontant depuis `start`.
105fn 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
118/// Dernier segment d'un chemin (nom de dossier), si présent.
119fn 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
125/// Affiche le projet courant (commande `mnemo project current`).
126pub 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
137/// Affiche les projets connus de l'historique (commande `mnemo project list`).
138pub 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
176/// Affiche le détail d'un projet (commande `mnemo project show`).
177pub 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/// Génère un rapport d'activité d'un projet (commande `mnemo project report`).
234#[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    // Toutes les commandes de la période (les plus récentes d'abord), bornées
254    // ensuite à `limit` pour la section détaillée.
255    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
302/// Résout la racine Git ciblée à partir d'un argument explicite ou `--current`.
303///
304/// Pour un argument explicite, accepte la racine complète ou le nom court du
305/// projet (suffixe). Une correspondance ambiguë est refusée explicitement.
306fn 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
337/// Remplace un préfixe `~` ou `~/` par le répertoire personnel.
338fn 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
351/// Résout une borne temporelle, en échouant proprement si la spec est invalide.
352fn 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
366/// Nom court d'un projet (dernier segment de la racine).
367fn short_name(root: &str) -> String {
368    base_name(Path::new(root)).unwrap_or_else(|| root.to_string())
369}
370
371/// Libellé compact d'une liste de branches, ou `-` si vide.
372fn branches_label(branches: &[String]) -> String {
373    if branches.is_empty() {
374        "-".to_string()
375    } else {
376        branches.join(", ")
377    }
378}
379
380/// Écrit une commande sur une ligne, en signalant les échecs par leur code.
381fn 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
393/// Données agrégées d'un rapport de projet, pour une période donnée.
394struct 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    /// Commandes détaillées (ordre chronologique croissant, bornées à `limit`).
405    detail: Vec<&'a CommandRecord>,
406    /// Échecs de la période (les plus récents d'abord).
407    failures: Vec<&'a CommandRecord>,
408}
409
410impl<'a> ReportData<'a> {
411    /// Construit les agrégats à partir des commandes de la période (triées de la
412    /// plus récente à la plus ancienne par `project_records`).
413    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        // `records` est décroissant : le dernier élément est le plus ancien.
436        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        // Détail chronologique : les `limit` plus récentes, réordonnées en
446        // ordre croissant pour une lecture naturelle.
447        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    /// Rendu Markdown réutilisable du rapport.
469    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    /// Document JSON stable du rapport.
560    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/// Projet sérialisé pour `mnemo project list --json`.
580#[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/// Métadonnées d'un projet sérialisées (show / report).
604#[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/// Commande sérialisée (show / report).
632#[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/// Document JSON de `mnemo project show`.
652#[derive(Serialize)]
653struct ProjectShowJson<'a> {
654    project: ProjectMetaJson,
655    recent: Vec<RecordJson<'a>>,
656    recent_failures: Vec<RecordJson<'a>>,
657}
658
659/// Document JSON de `mnemo project report`.
660#[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/// Agrégats de la période dans l'export JSON.
669#[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        // Le plus récemment actif vient en tête.
778        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}