Skip to main content

rec/session/
tags.rs

1//! Tags listing command implementation.
2//!
3//! Lists all tags across all sessions with session counts, sorted by
4//! count descending (most used first), then alphabetically for ties.
5
6use crate::cli::Output;
7use crate::error::Result;
8use crate::storage::SessionStore;
9use std::collections::HashMap;
10
11/// List all tags with session counts.
12///
13/// Loads headers for all sessions and collects tag counts. Output is sorted
14/// by count descending, then alphabetically for ties.
15///
16/// # JSON mode
17/// Outputs a JSON array of `{tag, count}` objects.
18///
19/// # Normal mode
20/// Prints each tag with its session count.
21///
22/// # Errors
23/// Returns an error if reading sessions from the store fails.
24pub fn list_tags(store: &SessionStore, json: bool, output: &Output) -> Result<()> {
25    let ids = store.list()?;
26
27    // Collect tag counts
28    let mut tag_counts: HashMap<String, usize> = HashMap::new();
29    for id in &ids {
30        if let Ok((header, _footer)) = store.load_header_and_footer(id) {
31            for tag in &header.tags {
32                *tag_counts.entry(tag.clone()).or_insert(0) += 1;
33            }
34        }
35    }
36
37    // Sort by count descending, then alphabetically for ties
38    let mut sorted_tags: Vec<(String, usize)> = tag_counts.into_iter().collect();
39    sorted_tags.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
40
41    // Handle empty state
42    if sorted_tags.is_empty() {
43        if json {
44            println!("[]");
45        } else {
46            output.info("No tags found. Add tags with: rec tag <session> <tag>");
47        }
48        return Ok(());
49    }
50
51    // JSON output
52    if json {
53        let json_entries: Vec<serde_json::Value> = sorted_tags
54            .iter()
55            .map(|(tag, count)| {
56                serde_json::json!({
57                    "tag": tag,
58                    "count": count,
59                })
60            })
61            .collect();
62
63        println!(
64            "{}",
65            serde_json::to_string_pretty(&json_entries).unwrap_or_else(|_| "[]".to_string())
66        );
67        return Ok(());
68    }
69
70    // Normal output
71    println!();
72    for (tag, count) in &sorted_tags {
73        let label = if *count == 1 { "session" } else { "sessions" };
74        println!("  {tag:<20} ({count} {label})");
75    }
76    println!();
77
78    Ok(())
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::models::{Command, Session, SessionStatus};
85    use crate::storage::Paths;
86    use std::path::PathBuf;
87    use tempfile::TempDir;
88
89    fn create_test_paths(temp_dir: &TempDir) -> Paths {
90        Paths {
91            data_dir: temp_dir.path().join("sessions"),
92            config_dir: temp_dir.path().join("config"),
93            config_file: temp_dir.path().join("config").join("config.toml"),
94            state_dir: temp_dir.path().join("state"),
95        }
96    }
97
98    fn create_tagged_session(name: &str, tags: Vec<String>) -> Session {
99        let mut session = Session::new(name);
100        session.header.tags = tags;
101        session.commands.push(Command::new(
102            0,
103            "echo hello".to_string(),
104            PathBuf::from("/tmp"),
105        ));
106        session.complete(SessionStatus::Completed);
107        session
108    }
109
110    #[test]
111    fn test_tag_counting() {
112        let temp_dir = TempDir::new().unwrap();
113        let paths = create_test_paths(&temp_dir);
114        let store = SessionStore::new(paths);
115
116        // Create sessions with tags
117        let s1 = create_tagged_session("s1", vec!["deploy".into(), "rust".into()]);
118        let s2 = create_tagged_session("s2", vec!["deploy".into(), "setup".into()]);
119        let s3 = create_tagged_session("s3", vec!["rust".into()]);
120        store.save(&s1).unwrap();
121        store.save(&s2).unwrap();
122        store.save(&s3).unwrap();
123
124        // Collect counts manually
125        let ids = store.list().unwrap();
126        let mut tag_counts: HashMap<String, usize> = HashMap::new();
127        for id in &ids {
128            if let Ok((header, _)) = store.load_header_and_footer(id) {
129                for tag in &header.tags {
130                    *tag_counts.entry(tag.clone()).or_insert(0) += 1;
131                }
132            }
133        }
134
135        assert_eq!(tag_counts.get("deploy"), Some(&2));
136        assert_eq!(tag_counts.get("rust"), Some(&2));
137        assert_eq!(tag_counts.get("setup"), Some(&1));
138    }
139
140    #[test]
141    fn test_empty_tags() {
142        let temp_dir = TempDir::new().unwrap();
143        let paths = create_test_paths(&temp_dir);
144        let store = SessionStore::new(paths);
145
146        // Session with no tags
147        let s = create_tagged_session("no-tags", vec![]);
148        store.save(&s).unwrap();
149
150        let ids = store.list().unwrap();
151        let mut tag_counts: HashMap<String, usize> = HashMap::new();
152        for id in &ids {
153            if let Ok((header, _)) = store.load_header_and_footer(id) {
154                for tag in &header.tags {
155                    *tag_counts.entry(tag.clone()).or_insert(0) += 1;
156                }
157            }
158        }
159
160        assert!(tag_counts.is_empty());
161    }
162}