1use 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#[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 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct Environment {
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub os: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub arch: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub branch: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub repo: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub git_user: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub git_email: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub chub_version: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub extended_thinking: Option<bool>,
80}
81
82impl Environment {
83 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, }
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 if let Some(url) = git_config_value(&["config", "--get", "remote.origin.url"]) {
120 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 std::env::current_dir()
133 .ok()
134 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
135}
136
137#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub env: Option<Environment>,
166}
167
168#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub env: Option<Environment>,
192}
193
194impl ActiveSession {
195 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, env: self.env,
219 }
220 }
221}
222
223pub fn generate_session_id() -> String {
229 let now = now_iso8601();
230 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
249pub 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
264pub 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
272pub 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
287pub 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
301pub 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
324pub fn end_session() -> Option<Session> {
326 let active = get_active_session()?;
327 let session = active.finalize();
328
329 write_session_summary(&session);
331
332 clear_active_session();
334
335 Some(session)
336}
337
338const SESSIONS_BRANCH: &str = "chub/sessions/v1";
346
347fn 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
358fn chub_sessions_dir() -> Option<PathBuf> {
360 project_chub_dir().map(|d| d.join("sessions"))
361}
362
363fn 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
374pub 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 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 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
405pub 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 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 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
461pub fn get_session(session_id: &str) -> Option<Session> {
464 let filename = format!("{}.yaml", session_id);
465
466 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 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 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
494pub fn push_sessions(remote: &str) -> bool {
496 crate::team::tracking::branch_store::push_branch(SESSIONS_BRANCH, remote)
497}
498
499#[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)>, pub by_model: Vec<(String, usize, u64)>, 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
574fn now_secs() -> u64 {
579 std::time::SystemTime::now()
580 .duration_since(std::time::UNIX_EPOCH)
581 .unwrap_or_default()
582 .as_secs()
583}
584
585fn parse_iso_to_secs(iso: &str) -> Option<u64> {
587 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 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 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 #[test]
748 fn session_shard_normal_id() {
749 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 #[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 #[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 #[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 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 #[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 #[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}