Skip to main content

chub_core/team/
sessions.rs

1//! Session model and summary persistence for AI usage tracking.
2//!
3//! Team-visible session summaries are stored as YAML in `.chub/sessions/`.
4//! Full transcripts go to `.git/chub-sessions/` (local-only, via session_journal).
5
6use std::collections::HashSet;
7use std::fs;
8use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12use crate::team::project::{find_project_root, project_chub_dir};
13use crate::util::now_iso8601;
14
15// ---------------------------------------------------------------------------
16// Data model
17// ---------------------------------------------------------------------------
18
19/// Token usage breakdown for a session.
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21pub struct TokenUsage {
22    #[serde(default)]
23    pub input: u64,
24    #[serde(default)]
25    pub output: u64,
26    #[serde(default)]
27    pub cache_read: u64,
28    #[serde(default)]
29    pub cache_write: u64,
30    /// Extended thinking / reasoning tokens.
31    #[serde(default, skip_serializing_if = "is_zero_u64")]
32    pub reasoning: u64,
33}
34
35fn is_zero_u64(v: &u64) -> bool {
36    *v == 0
37}
38
39impl TokenUsage {
40    pub fn total(&self) -> u64 {
41        self.input + self.output + self.cache_read + self.cache_write + self.reasoning
42    }
43
44    pub fn add(&mut self, other: &TokenUsage) {
45        self.input += other.input;
46        self.output += other.output;
47        self.cache_read += other.cache_read;
48        self.cache_write += other.cache_write;
49        self.reasoning += other.reasoning;
50    }
51}
52
53/// Environment snapshot captured at session start.
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct Environment {
56    /// Operating system (e.g. "windows", "macos", "linux").
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub os: Option<String>,
59    /// CPU architecture (e.g. "x86_64", "aarch64").
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub arch: Option<String>,
62    /// Git branch at session start.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub branch: Option<String>,
65    /// Repository name (from remote URL or directory name).
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub repo: Option<String>,
68    /// Git user.name.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub git_user: Option<String>,
71    /// Git user.email.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub git_email: Option<String>,
74    /// Chub CLI version.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub chub_version: Option<String>,
77    /// Whether extended thinking / reasoning was used.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub extended_thinking: Option<bool>,
80}
81
82impl Environment {
83    /// Capture the current environment.
84    pub fn capture() -> Self {
85        Self {
86            os: Some(std::env::consts::OS.to_string()),
87            arch: Some(std::env::consts::ARCH.to_string()),
88            branch: git_config_value(&["rev-parse", "--abbrev-ref", "HEAD"]),
89            repo: detect_repo_name(),
90            git_user: git_config_value(&["config", "user.name"]),
91            git_email: git_config_value(&["config", "user.email"]),
92            chub_version: Some(env!("CARGO_PKG_VERSION").to_string()),
93            extended_thinking: None, // set later from transcript analysis
94        }
95    }
96}
97
98fn git_config_value(args: &[&str]) -> Option<String> {
99    std::process::Command::new("git")
100        .args(args)
101        .output()
102        .ok()
103        .and_then(|o| {
104            if o.status.success() {
105                let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
106                if s.is_empty() || s == "HEAD" {
107                    None
108                } else {
109                    Some(s)
110                }
111            } else {
112                None
113            }
114        })
115}
116
117fn detect_repo_name() -> Option<String> {
118    // Try remote URL first
119    if let Some(url) = git_config_value(&["config", "--get", "remote.origin.url"]) {
120        // Parse repo name from git URL: git@github.com:user/repo.git or https://github.com/user/repo.git
121        let name = url
122            .trim_end_matches(".git")
123            .rsplit('/')
124            .next()
125            .or_else(|| url.trim_end_matches(".git").rsplit(':').next())
126            .map(|s| s.to_string());
127        if name.as_deref() != Some("") {
128            return name;
129        }
130    }
131    // Fall back to directory name
132    std::env::current_dir()
133        .ok()
134        .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
135}
136
137/// A session summary (team-visible, stored in `.chub/sessions/`).
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Session {
140    pub session_id: String,
141    pub agent: String,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub model: Option<String>,
144    pub started_at: String,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub ended_at: Option<String>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub duration_s: Option<u64>,
149    #[serde(default)]
150    pub turns: u32,
151    #[serde(default)]
152    pub tokens: TokenUsage,
153    #[serde(default)]
154    pub tool_calls: u32,
155    #[serde(default)]
156    pub tools_used: Vec<String>,
157    #[serde(default)]
158    pub files_changed: Vec<String>,
159    #[serde(default)]
160    pub commits: Vec<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub est_cost_usd: Option<f64>,
163    /// Environment snapshot from session start.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub env: Option<Environment>,
166}
167
168/// Active session state (kept in `.git/chub-sessions/active.json`).
169/// This tracks the in-progress session and gets finalized to a Session summary.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct ActiveSession {
172    pub session_id: String,
173    pub agent: String,
174    #[serde(default)]
175    pub model: Option<String>,
176    pub started_at: String,
177    #[serde(default)]
178    pub turns: u32,
179    #[serde(default)]
180    pub tokens: TokenUsage,
181    #[serde(default)]
182    pub tool_calls: u32,
183    #[serde(default)]
184    pub tools_used: HashSet<String>,
185    #[serde(default)]
186    pub files_changed: HashSet<String>,
187    #[serde(default)]
188    pub commits: Vec<String>,
189    /// Environment snapshot from session start.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub env: Option<Environment>,
192}
193
194impl ActiveSession {
195    /// Convert to a finalized Session summary.
196    pub fn finalize(self) -> Session {
197        let ended_at = now_iso8601();
198        let duration_s = calc_duration_s(&self.started_at, &ended_at);
199        let mut tools_used: Vec<String> = self.tools_used.into_iter().collect();
200        tools_used.sort();
201        let mut files_changed: Vec<String> = self.files_changed.into_iter().collect();
202        files_changed.sort();
203
204        Session {
205            session_id: self.session_id,
206            agent: self.agent,
207            model: self.model,
208            started_at: self.started_at,
209            ended_at: Some(ended_at),
210            duration_s,
211            turns: self.turns,
212            tokens: self.tokens,
213            tool_calls: self.tool_calls,
214            tools_used,
215            files_changed,
216            commits: self.commits,
217            est_cost_usd: None, // Calculated by cost module
218            env: self.env,
219        }
220    }
221}
222
223// ---------------------------------------------------------------------------
224// Session ID generation
225// ---------------------------------------------------------------------------
226
227/// Generate a session ID in the format `YYYY-MM-DDTHH-MM-<6hex>`.
228pub fn generate_session_id() -> String {
229    let now = now_iso8601();
230    // Take "2026-03-22T10:05:00" → "2026-03-22T10-05"
231    let prefix = now.get(..16).unwrap_or(&now).replace(':', "-");
232    let hex = random_hex(6);
233    format!("{}-{}", prefix, hex)
234}
235
236fn random_hex(len: usize) -> String {
237    use std::collections::hash_map::DefaultHasher;
238    use std::hash::{Hash, Hasher};
239
240    let mut hasher = DefaultHasher::new();
241    std::time::SystemTime::now().hash(&mut hasher);
242    std::process::id().hash(&mut hasher);
243    std::thread::current().id().hash(&mut hasher);
244    let hash = hasher.finish();
245    let hex = format!("{:016x}", hash);
246    hex[..len.min(16)].to_string()
247}
248
249// ---------------------------------------------------------------------------
250// Active session persistence (.git/chub-sessions/)
251// ---------------------------------------------------------------------------
252
253/// Get the `.git/chub-sessions/` directory (local-only, not pushed).
254pub fn git_sessions_dir() -> Option<PathBuf> {
255    let project_root = find_project_root(None)?;
256    let git_dir = project_root.join(".git");
257    if git_dir.is_dir() {
258        Some(git_dir.join("chub-sessions"))
259    } else {
260        None
261    }
262}
263
264/// Get the active session, if any.
265pub fn get_active_session() -> Option<ActiveSession> {
266    let dir = git_sessions_dir()?;
267    let path = dir.join("active.json");
268    let content = fs::read_to_string(&path).ok()?;
269    serde_json::from_str(&content).ok()
270}
271
272/// Save the active session state.
273pub fn save_active_session(session: &ActiveSession) -> bool {
274    let dir = match git_sessions_dir() {
275        Some(d) => d,
276        None => return false,
277    };
278    let _ = fs::create_dir_all(&dir);
279    let path = dir.join("active.json");
280    let json = match serde_json::to_string_pretty(session) {
281        Ok(j) => j,
282        Err(_) => return false,
283    };
284    crate::util::atomic_write(&path, json.as_bytes()).is_ok()
285}
286
287/// Clear the active session.
288pub fn clear_active_session() -> bool {
289    let dir = match git_sessions_dir() {
290        Some(d) => d,
291        None => return false,
292    };
293    let path = dir.join("active.json");
294    if path.exists() {
295        fs::remove_file(&path).is_ok()
296    } else {
297        true
298    }
299}
300
301/// Start a new session. Returns the session ID.
302pub fn start_session(agent: &str, model: Option<&str>) -> Option<String> {
303    let session_id = generate_session_id();
304    let session = ActiveSession {
305        session_id: session_id.clone(),
306        agent: agent.to_string(),
307        model: model.map(|s| s.to_string()),
308        started_at: now_iso8601(),
309        turns: 0,
310        tokens: TokenUsage::default(),
311        tool_calls: 0,
312        tools_used: HashSet::new(),
313        files_changed: HashSet::new(),
314        commits: Vec::new(),
315        env: Some(Environment::capture()),
316    };
317    if save_active_session(&session) {
318        Some(session_id)
319    } else {
320        None
321    }
322}
323
324/// End the active session, finalize it, and write the summary.
325pub fn end_session() -> Option<Session> {
326    let active = get_active_session()?;
327    let session = active.finalize();
328
329    // Write summary to .chub/sessions/
330    write_session_summary(&session);
331
332    // Clear active state
333    clear_active_session();
334
335    Some(session)
336}
337
338// ---------------------------------------------------------------------------
339// Session summaries
340// Primary store: `.git/chub/sessions/` (local-only, fast reads)
341// Team store: `chub/sessions/v1` orphan branch (pushed via pre-push hook)
342// Legacy: `.chub/sessions/` (git-tracked, kept for migration reads)
343// ---------------------------------------------------------------------------
344
345const SESSIONS_BRANCH: &str = "chub/sessions/v1";
346
347/// Primary session dir inside `.git` (local-only).
348fn git_session_summaries_dir() -> Option<PathBuf> {
349    let project_root = find_project_root(None)?;
350    let git_dir = project_root.join(".git");
351    if git_dir.is_dir() {
352        Some(git_dir.join("chub").join("sessions"))
353    } else {
354        None
355    }
356}
357
358/// Legacy session dir in `.chub/sessions/` (git-tracked, read-only for migration).
359fn chub_sessions_dir() -> Option<PathBuf> {
360    project_chub_dir().map(|d| d.join("sessions"))
361}
362
363/// Shard prefix for a session ID (last 2 hex chars of the random suffix).
364/// Session IDs look like `2026-03-22T10-05-abc123` — uses `ab` as shard.
365fn session_shard(session_id: &str) -> String {
366    let len = session_id.len();
367    if len >= 6 {
368        session_id[len - 6..len - 4].to_string()
369    } else {
370        "00".to_string()
371    }
372}
373
374/// Write a finalized session summary as YAML.
375/// Writes to `.git/chub/sessions/` (local) and `chub/sessions/v1` (orphan branch).
376pub fn write_session_summary(session: &Session) -> bool {
377    let yaml = match serde_yaml::to_string(session) {
378        Ok(y) => y,
379        Err(_) => return false,
380    };
381    let filename = format!("{}.yaml", session.session_id);
382    let mut wrote = false;
383
384    // 1. Local fast store: .git/chub/sessions/
385    if let Some(dir) = git_session_summaries_dir() {
386        let _ = fs::create_dir_all(&dir);
387        let path = dir.join(&filename);
388        if crate::util::atomic_write(&path, yaml.as_bytes()).is_ok() {
389            wrote = true;
390        }
391    }
392
393    // 2. Orphan branch: chub/sessions/v1 (team-visible, pushed via pre-push)
394    let shard = session_shard(&session.session_id);
395    let branch_path = format!("{}/{}", shard, filename);
396    let files: Vec<(&str, &[u8])> = vec![(&branch_path, yaml.as_bytes())];
397    let commit_msg = format!("Session: {}", session.session_id);
398    if crate::team::tracking::branch_store::write_files(SESSIONS_BRANCH, &files, &commit_msg) {
399        wrote = true;
400    }
401
402    wrote
403}
404
405/// List all session summaries, most recent first.
406/// Reads from: 1) `.git/chub/sessions/` (local), 2) `chub/sessions/v1` branch,
407/// 3) `.chub/sessions/` (legacy fallback). Deduplicates by session_id.
408pub fn list_sessions(days: u64) -> Vec<Session> {
409    let cutoff = now_secs().saturating_sub(days * 86400);
410    let mut seen_ids = std::collections::HashSet::new();
411    let mut sessions = Vec::new();
412
413    // 1. Local filesystem dirs
414    let dirs: Vec<Option<PathBuf>> = vec![git_session_summaries_dir(), chub_sessions_dir()];
415    for dir in dirs.into_iter().flatten() {
416        if !dir.is_dir() {
417            continue;
418        }
419        for entry in fs::read_dir(&dir).ok().into_iter().flatten().flatten() {
420            if entry
421                .path()
422                .extension()
423                .map(|ext| ext == "yaml")
424                .unwrap_or(false)
425            {
426                if let Ok(content) = fs::read_to_string(entry.path()) {
427                    if let Ok(s) = serde_yaml::from_str::<Session>(&content) {
428                        if parse_iso_to_secs(&s.started_at).unwrap_or(0) >= cutoff
429                            && seen_ids.insert(s.session_id.clone())
430                        {
431                            sessions.push(s);
432                        }
433                    }
434                }
435            }
436        }
437    }
438
439    // 2. Orphan branch (picks up team members' sessions after fetch)
440    let branch_files = crate::team::tracking::branch_store::list_files(SESSIONS_BRANCH);
441    for file in &branch_files {
442        if file.ends_with(".yaml") {
443            if let Some(content) =
444                crate::team::tracking::branch_store::read_file(SESSIONS_BRANCH, file)
445            {
446                if let Ok(s) = serde_yaml::from_slice::<Session>(&content) {
447                    if parse_iso_to_secs(&s.started_at).unwrap_or(0) >= cutoff
448                        && seen_ids.insert(s.session_id.clone())
449                    {
450                        sessions.push(s);
451                    }
452                }
453            }
454        }
455    }
456
457    sessions.sort_by(|a, b| b.started_at.cmp(&a.started_at));
458    sessions
459}
460
461/// Get a session by ID.
462/// Checks: 1) `.git/chub/sessions/`, 2) orphan branch, 3) `.chub/sessions/` (legacy).
463pub fn get_session(session_id: &str) -> Option<Session> {
464    let filename = format!("{}.yaml", session_id);
465
466    // 1. Local fast store
467    if let Some(dir) = git_session_summaries_dir() {
468        let path = dir.join(&filename);
469        if let Ok(content) = fs::read_to_string(&path) {
470            if let Ok(s) = serde_yaml::from_str(&content) {
471                return Some(s);
472            }
473        }
474    }
475
476    // 2. Orphan branch
477    let shard = session_shard(session_id);
478    let branch_path = format!("{}/{}", shard, filename);
479    if let Some(content) =
480        crate::team::tracking::branch_store::read_file(SESSIONS_BRANCH, &branch_path)
481    {
482        if let Ok(s) = serde_yaml::from_slice(&content) {
483            return Some(s);
484        }
485    }
486
487    // 3. Legacy fallback
488    let dir = chub_sessions_dir()?;
489    let path = dir.join(&filename);
490    let content = fs::read_to_string(&path).ok()?;
491    serde_yaml::from_str(&content).ok()
492}
493
494/// Push the sessions branch to a remote.
495pub fn push_sessions(remote: &str) -> bool {
496    crate::team::tracking::branch_store::push_branch(SESSIONS_BRANCH, remote)
497}
498
499// ---------------------------------------------------------------------------
500// Aggregate report
501// ---------------------------------------------------------------------------
502
503/// Aggregate stats across sessions.
504#[derive(Debug, Clone, Serialize)]
505pub struct SessionReport {
506    pub period_days: u64,
507    pub session_count: usize,
508    pub total_duration_s: u64,
509    pub total_tokens: TokenUsage,
510    pub total_tool_calls: u32,
511    pub total_est_cost_usd: f64,
512    pub by_agent: Vec<(String, usize, f64)>, // (agent, sessions, cost)
513    pub by_model: Vec<(String, usize, u64)>, // (model, sessions, tokens)
514    pub top_tools: Vec<(String, u32)>,
515}
516
517pub fn generate_report(days: u64) -> SessionReport {
518    let sessions = list_sessions(days);
519    let mut total_tokens = TokenUsage::default();
520    let mut total_duration_s = 0u64;
521    let mut total_tool_calls = 0u32;
522    let mut total_cost = 0.0f64;
523    let mut agent_map: std::collections::HashMap<String, (usize, f64)> =
524        std::collections::HashMap::new();
525    let mut model_map: std::collections::HashMap<String, (usize, u64)> =
526        std::collections::HashMap::new();
527    let mut tool_map: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
528
529    for s in &sessions {
530        total_tokens.add(&s.tokens);
531        total_duration_s += s.duration_s.unwrap_or(0);
532        total_tool_calls += s.tool_calls;
533        let cost = s.est_cost_usd.unwrap_or(0.0);
534        total_cost += cost;
535
536        let ae = agent_map.entry(s.agent.clone()).or_insert((0, 0.0));
537        ae.0 += 1;
538        ae.1 += cost;
539
540        if let Some(ref model) = s.model {
541            let me = model_map.entry(model.clone()).or_insert((0, 0));
542            me.0 += 1;
543            me.1 += s.tokens.total();
544        }
545
546        for tool in &s.tools_used {
547            *tool_map.entry(tool.clone()).or_insert(0) +=
548                s.tool_calls / s.tools_used.len().max(1) as u32;
549        }
550    }
551
552    let mut by_agent: Vec<_> = agent_map.into_iter().map(|(k, v)| (k, v.0, v.1)).collect();
553    by_agent.sort_by(|a, b| b.1.cmp(&a.1));
554
555    let mut by_model: Vec<_> = model_map.into_iter().map(|(k, v)| (k, v.0, v.1)).collect();
556    by_model.sort_by(|a, b| b.2.cmp(&a.2));
557
558    let mut top_tools: Vec<_> = tool_map.into_iter().collect();
559    top_tools.sort_by(|a, b| b.1.cmp(&a.1));
560
561    SessionReport {
562        period_days: days,
563        session_count: sessions.len(),
564        total_duration_s,
565        total_tokens,
566        total_tool_calls,
567        total_est_cost_usd: total_cost,
568        by_agent,
569        by_model,
570        top_tools,
571    }
572}
573
574// ---------------------------------------------------------------------------
575// Helpers
576// ---------------------------------------------------------------------------
577
578fn now_secs() -> u64 {
579    std::time::SystemTime::now()
580        .duration_since(std::time::UNIX_EPOCH)
581        .unwrap_or_default()
582        .as_secs()
583}
584
585/// Parse a simplified ISO 8601 timestamp to seconds since epoch.
586fn parse_iso_to_secs(iso: &str) -> Option<u64> {
587    // Parse "2026-03-22T10:05:00.000Z" or similar
588    let clean = iso.trim().trim_end_matches('Z');
589    let parts: Vec<&str> = clean.split('T').collect();
590    if parts.len() != 2 {
591        return None;
592    }
593    let date_parts: Vec<u64> = parts[0].split('-').filter_map(|p| p.parse().ok()).collect();
594    if date_parts.len() != 3 {
595        return None;
596    }
597    let time_clean = parts[1].split('.').next()?;
598    let time_parts: Vec<u64> = time_clean
599        .split(':')
600        .filter_map(|p| p.parse().ok())
601        .collect();
602    if time_parts.len() != 3 {
603        return None;
604    }
605
606    let (y, m, d) = (date_parts[0], date_parts[1], date_parts[2]);
607    let (h, min, s) = (time_parts[0], time_parts[1], time_parts[2]);
608
609    // Simplified days calculation (good enough for relative comparisons)
610    let days = y * 365 + y / 4 - y / 100 + y / 400 + (m * 30) + d;
611    Some(days * 86400 + h * 3600 + min * 60 + s)
612}
613
614fn calc_duration_s(start: &str, end: &str) -> Option<u64> {
615    let s = parse_iso_to_secs(start)?;
616    let e = parse_iso_to_secs(end)?;
617    Some(e.saturating_sub(s))
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    #[test]
625    fn session_id_format() {
626        let id = generate_session_id();
627        assert!(id.len() > 20, "session ID should be substantial: {}", id);
628        assert!(
629            id.contains('T'),
630            "session ID should contain T separator: {}",
631            id
632        );
633    }
634
635    #[test]
636    fn token_usage_add() {
637        let mut a = TokenUsage {
638            input: 100,
639            output: 50,
640            cache_read: 10,
641            cache_write: 5,
642            ..Default::default()
643        };
644        let b = TokenUsage {
645            input: 200,
646            output: 100,
647            cache_read: 20,
648            cache_write: 10,
649            ..Default::default()
650        };
651        a.add(&b);
652        assert_eq!(a.input, 300);
653        assert_eq!(a.output, 150);
654        assert_eq!(a.total(), 300 + 150 + 30 + 15);
655    }
656
657    #[test]
658    fn parse_iso_round_trip() {
659        let ts = "2026-03-22T10:05:00.000Z";
660        let secs = parse_iso_to_secs(ts);
661        assert!(secs.is_some());
662    }
663
664    #[test]
665    fn session_yaml_roundtrip() {
666        let session = Session {
667            session_id: "2026-03-22T10-05-abc123".to_string(),
668            agent: "claude-code".to_string(),
669            model: Some("claude-opus-4-6".to_string()),
670            started_at: "2026-03-22T10:05:00.000Z".to_string(),
671            ended_at: Some("2026-03-22T10:42:00.000Z".to_string()),
672            duration_s: Some(2220),
673            turns: 14,
674            tokens: TokenUsage {
675                input: 45000,
676                output: 12000,
677                cache_read: 8000,
678                cache_write: 3000,
679                ..Default::default()
680            },
681            tool_calls: 23,
682            tools_used: vec!["Read".to_string(), "Edit".to_string()],
683            files_changed: vec!["src/main.rs".to_string()],
684            commits: vec!["abc1234".to_string()],
685            est_cost_usd: Some(0.85),
686            env: None,
687        };
688        let yaml = serde_yaml::to_string(&session).unwrap();
689        let parsed: Session = serde_yaml::from_str(&yaml).unwrap();
690        assert_eq!(parsed.session_id, session.session_id);
691        assert_eq!(parsed.tokens.input, 45000);
692        assert_eq!(parsed.est_cost_usd, Some(0.85));
693    }
694
695    #[test]
696    fn environment_capture_returns_os_and_arch() {
697        let env = Environment::capture();
698        assert!(env.os.is_some(), "os should be captured");
699        assert!(env.arch.is_some(), "arch should be captured");
700        assert!(env.chub_version.is_some(), "chub_version should be set");
701        // extended_thinking starts as None
702        assert!(env.extended_thinking.is_none());
703    }
704
705    #[test]
706    fn session_yaml_roundtrip_with_env() {
707        let session = Session {
708            session_id: "2026-03-22T10-05-env123".to_string(),
709            agent: "claude-code".to_string(),
710            model: Some("claude-opus-4-6".to_string()),
711            started_at: "2026-03-22T10:05:00.000Z".to_string(),
712            ended_at: Some("2026-03-22T10:42:00.000Z".to_string()),
713            duration_s: Some(2220),
714            turns: 14,
715            tokens: TokenUsage::default(),
716            tool_calls: 0,
717            tools_used: vec![],
718            files_changed: vec![],
719            commits: vec![],
720            est_cost_usd: None,
721            env: Some(Environment {
722                os: Some("windows".to_string()),
723                arch: Some("x86_64".to_string()),
724                branch: Some("main".to_string()),
725                repo: Some("my-project".to_string()),
726                git_user: Some("Jane".to_string()),
727                git_email: Some("jane@chub.nrl.ai".to_string()),
728                chub_version: Some("0.1.15".to_string()),
729                extended_thinking: Some(true),
730            }),
731        };
732        let yaml = serde_yaml::to_string(&session).unwrap();
733        assert!(yaml.contains("os: windows"));
734        assert!(yaml.contains("extended_thinking: true"));
735
736        let parsed: Session = serde_yaml::from_str(&yaml).unwrap();
737        let env = parsed.env.unwrap();
738        assert_eq!(env.os.as_deref(), Some("windows"));
739        assert_eq!(env.arch.as_deref(), Some("x86_64"));
740        assert_eq!(env.branch.as_deref(), Some("main"));
741        assert_eq!(env.repo.as_deref(), Some("my-project"));
742        assert_eq!(env.extended_thinking, Some(true));
743    }
744
745    // --- Session shard ---
746
747    #[test]
748    fn session_shard_normal_id() {
749        // "2026-03-22T10-05-abc123" → shard = "ab" (chars at [-6..-4])
750        assert_eq!(session_shard("2026-03-22T10-05-abc123"), "ab");
751    }
752
753    #[test]
754    fn session_shard_various_ids() {
755        assert_eq!(session_shard("2026-03-28T04-54-9e3efd"), "9e");
756        assert_eq!(session_shard("2026-03-28T04-54-64bd27"), "64");
757        assert_eq!(session_shard("2026-03-28T11-22-ff0011"), "ff");
758    }
759
760    #[test]
761    fn session_shard_short_id() {
762        assert_eq!(session_shard("abc"), "00", "short IDs should return 00");
763        assert_eq!(session_shard(""), "00");
764    }
765
766    // --- parse_iso_to_secs edge cases ---
767
768    #[test]
769    fn parse_iso_missing_time() {
770        assert!(parse_iso_to_secs("2026-03-22").is_none());
771    }
772
773    #[test]
774    fn parse_iso_garbage() {
775        assert!(parse_iso_to_secs("").is_none());
776        assert!(parse_iso_to_secs("not-a-date").is_none());
777        assert!(parse_iso_to_secs("T10:00:00").is_none());
778    }
779
780    #[test]
781    fn parse_iso_with_z_suffix() {
782        let with_z = parse_iso_to_secs("2026-03-22T10:05:30.000Z");
783        let without_z = parse_iso_to_secs("2026-03-22T10:05:30.000");
784        assert_eq!(with_z, without_z, "Z suffix should not affect parsing");
785    }
786
787    #[test]
788    fn parse_iso_with_milliseconds() {
789        let result = parse_iso_to_secs("2026-03-22T10:05:30.123Z");
790        assert!(result.is_some());
791    }
792
793    // --- calc_duration_s ---
794
795    #[test]
796    fn duration_same_time_is_zero() {
797        let d = calc_duration_s("2026-03-22T10:00:00.000Z", "2026-03-22T10:00:00.000Z");
798        assert_eq!(d, Some(0));
799    }
800
801    #[test]
802    fn duration_one_hour() {
803        let d = calc_duration_s("2026-03-22T10:00:00.000Z", "2026-03-22T11:00:00.000Z");
804        assert_eq!(d, Some(3600));
805    }
806
807    #[test]
808    fn duration_end_before_start_is_zero() {
809        let d = calc_duration_s("2026-03-22T11:00:00.000Z", "2026-03-22T10:00:00.000Z");
810        assert_eq!(d, Some(0), "saturating_sub should prevent underflow");
811    }
812
813    // --- ActiveSession finalize ---
814
815    #[test]
816    fn active_session_finalize_sorts_fields() {
817        let active = ActiveSession {
818            session_id: "test-123".to_string(),
819            agent: "claude-code".to_string(),
820            model: Some("opus".to_string()),
821            started_at: "2026-03-22T10:00:00.000Z".to_string(),
822            turns: 5,
823            tokens: TokenUsage {
824                input: 1000,
825                output: 500,
826                ..Default::default()
827            },
828            tool_calls: 3,
829            tools_used: ["Edit", "Read", "Bash"]
830                .iter()
831                .map(|s| s.to_string())
832                .collect(),
833            files_changed: ["z.rs", "a.rs", "m.rs"]
834                .iter()
835                .map(|s| s.to_string())
836                .collect(),
837            commits: vec!["abc".to_string(), "def".to_string()],
838            env: None,
839        };
840
841        let session = active.finalize();
842        assert_eq!(session.agent, "claude-code");
843        assert!(session.ended_at.is_some());
844        assert!(session.duration_s.is_some());
845        // tools_used and files_changed should be sorted
846        assert_eq!(session.tools_used, vec!["Bash", "Edit", "Read"]);
847        assert_eq!(session.files_changed, vec!["a.rs", "m.rs", "z.rs"]);
848        assert_eq!(session.commits, vec!["abc", "def"]);
849    }
850
851    // --- Token usage ---
852
853    #[test]
854    fn token_usage_total_with_reasoning() {
855        let t = TokenUsage {
856            input: 100,
857            output: 50,
858            cache_read: 10,
859            cache_write: 5,
860            reasoning: 200,
861        };
862        assert_eq!(t.total(), 365);
863    }
864
865    // --- Environment ---
866
867    #[test]
868    fn environment_default_all_none() {
869        let env = Environment::default();
870        assert!(env.os.is_none());
871        assert!(env.arch.is_none());
872        assert!(env.branch.is_none());
873        assert!(env.repo.is_none());
874        assert!(env.git_user.is_none());
875        assert!(env.git_email.is_none());
876        assert!(env.chub_version.is_none());
877        assert!(env.extended_thinking.is_none());
878    }
879
880    #[test]
881    fn environment_none_fields_skipped_in_yaml() {
882        let session = Session {
883            session_id: "test".to_string(),
884            agent: "test".to_string(),
885            model: None,
886            started_at: "2026-01-01T00:00:00.000Z".to_string(),
887            ended_at: None,
888            duration_s: None,
889            turns: 0,
890            tokens: TokenUsage::default(),
891            tool_calls: 0,
892            tools_used: vec![],
893            files_changed: vec![],
894            commits: vec![],
895            est_cost_usd: None,
896            env: None,
897        };
898        let yaml = serde_yaml::to_string(&session).unwrap();
899        assert!(!yaml.contains("env:"), "env should be omitted when None");
900        assert!(
901            !yaml.contains("model:"),
902            "model should be omitted when None"
903        );
904        assert!(
905            !yaml.contains("est_cost_usd:"),
906            "cost should be omitted when None"
907        );
908    }
909}