Skip to main content

autom8/commands/
improve.rs

1//! Improve command handler.
2//!
3//! Gathers context from git and previous autom8 sessions to enable
4//! follow-up work with Claude. This command auto-detects everything
5//! from the current git branch.
6
7use crate::claude::run_improve_session;
8use crate::error::{Autom8Error, Result};
9use crate::gh::find_spec_for_branch;
10use crate::git::{self, CommitInfo, DiffEntry};
11use crate::knowledge::ProjectKnowledge;
12use crate::output::improve::print_context_summary;
13use crate::spec::Spec;
14use crate::state::StateManager;
15use std::path::PathBuf;
16
17// ============================================================================
18// US-008: Command Handler
19// ============================================================================
20
21/// Run the improve command.
22///
23/// This command gathers context from git and previous autom8 sessions,
24/// displays a summary, and spawns an interactive Claude session for follow-up work.
25///
26/// The workflow is:
27/// 1. Check if we're in a git repo (required)
28/// 2. Gather context (git, spec, session knowledge)
29/// 3. Display summary to the user (including any edge case warnings)
30/// 4. Build the context prompt
31/// 5. Spawn interactive Claude session
32///
33/// Edge cases handled:
34/// - Not in a git repo: shows error with suggestion
35/// - On main/master branch: shows warning but proceeds
36/// - No session/spec found: shows message that only git context is available
37/// - No commits vs base: shows "no commits yet" in summary
38///
39/// # Arguments
40/// * `_verbose` - Reserved for future extensibility (currently unused)
41///
42/// # Returns
43/// * `Ok(())` - Session completed
44/// * `Err` - Not in git repo, context loading failed, or Claude spawn failed
45pub fn improve_command(_verbose: bool) -> Result<()> {
46    // Step 0: Check if we're in a git repo (fatal error if not)
47    if !git::is_git_repo() {
48        return Err(Autom8Error::NotInGitRepo);
49    }
50
51    // Step 1: Gather all context layers
52    let context = load_follow_up_context()?;
53
54    // Step 2: Display summary to the user
55    print_context_summary(&context);
56
57    // Step 3: Build the context prompt
58    let prompt = build_improve_prompt(&context);
59
60    // Step 4: Spawn interactive Claude session
61    run_improve_session(&prompt)?;
62
63    Ok(())
64}
65
66// ============================================================================
67// US-001: Git Context Gathering
68// ============================================================================
69
70/// Git context for the current branch.
71///
72/// Contains all git-related context needed for the improve command.
73/// This is Layer 1 context - always available when in a git repo.
74#[derive(Debug, Clone)]
75pub struct GitContext {
76    /// Current branch name
77    pub branch_name: String,
78    /// Base branch name (main/master)
79    pub base_branch: String,
80    /// All commits on this branch since diverging from base
81    pub commits: Vec<CommitInfo>,
82    /// All file changes since diverging from base
83    pub diff_entries: Vec<DiffEntry>,
84    /// The merge-base commit hash (common ancestor with base branch)
85    pub merge_base_commit: Option<String>,
86}
87
88impl GitContext {
89    /// Check if this is a feature branch (not main/master).
90    pub fn is_feature_branch(&self) -> bool {
91        self.branch_name != "main" && self.branch_name != "master"
92    }
93
94    /// Get the number of commits on this branch.
95    pub fn commit_count(&self) -> usize {
96        self.commits.len()
97    }
98
99    /// Get the total number of files changed.
100    pub fn files_changed_count(&self) -> usize {
101        self.diff_entries.len()
102    }
103
104    /// Get total additions across all changed files.
105    pub fn total_additions(&self) -> u32 {
106        self.diff_entries.iter().map(|e| e.additions).sum()
107    }
108
109    /// Get total deletions across all changed files.
110    pub fn total_deletions(&self) -> u32 {
111        self.diff_entries.iter().map(|e| e.deletions).sum()
112    }
113}
114
115/// Gather git context for the current branch.
116///
117/// This function collects:
118/// - Current branch name
119/// - Base branch (main/master)
120/// - All commits since diverging from base
121/// - All file changes since merge-base
122///
123/// Errors are handled gracefully - if certain operations fail,
124/// the function will return partial context with empty collections.
125///
126/// # Returns
127/// * `Ok(GitContext)` - Git context for the current branch
128/// * `Err` - Only if we cannot get the current branch (fatal)
129pub fn gather_git_context() -> Result<GitContext> {
130    // Get current branch - this is required
131    let branch_name = git::current_branch()?;
132
133    // Detect base branch (main/master) - defaults to "main" if detection fails
134    let base_branch = git::detect_base_branch().unwrap_or_else(|_| "main".to_string());
135
136    // Get merge-base commit for accurate diff calculation
137    let merge_base_commit = git::get_merge_base(&base_branch).ok();
138
139    // Get commits since base branch
140    // If this fails (e.g., branch doesn't exist), return empty vec
141    let commits = git::get_branch_commits(&base_branch).unwrap_or_default();
142
143    // Get diff entries since merge-base
144    // Use merge-base if available, otherwise compare against base branch directly
145    let diff_entries = if let Some(ref merge_base) = merge_base_commit {
146        git::get_diff_since(merge_base).unwrap_or_default()
147    } else {
148        // Fallback: try to diff against base branch directly
149        git::get_diff_since(&base_branch).unwrap_or_default()
150    };
151
152    Ok(GitContext {
153        branch_name,
154        base_branch,
155        commits,
156        diff_entries,
157        merge_base_commit,
158    })
159}
160
161// ============================================================================
162// US-005: Build Context Prompt
163// ============================================================================
164
165/// Build a conversational, concise prompt summarizing the loaded context.
166///
167/// This prompt is shown to Claude at the start of an interactive improve session.
168/// It acknowledges what context is available and ends with an open question.
169///
170/// # Arguments
171/// * `context` - The follow-up context containing git, spec, and knowledge info
172///
173/// # Returns
174/// A formatted prompt string (target: under ~500 words)
175pub fn build_improve_prompt(context: &FollowUpContext) -> String {
176    let mut sections: Vec<String> = Vec::new();
177
178    // Opening statement acknowledging branch and what's loaded
179    let opening = build_opening_statement(context);
180    sections.push(opening);
181
182    // Spec summary if available
183    if let Some(ref spec) = context.spec {
184        sections.push(build_spec_summary(spec));
185    }
186
187    // Key decisions if available
188    if let Some(ref knowledge) = context.knowledge {
189        if !knowledge.decisions.is_empty() {
190            sections.push(build_decisions_summary(&knowledge.decisions));
191        }
192    }
193
194    // Files touched if available
195    if let Some(ref knowledge) = context.knowledge {
196        if !knowledge.story_changes.is_empty() {
197            if let Some(files_section) = build_files_summary(&knowledge.story_changes) {
198                sections.push(files_section);
199            }
200        }
201    }
202
203    // Work summaries if available
204    if !context.work_summaries.is_empty() {
205        sections.push(build_work_summaries(&context.work_summaries));
206    }
207
208    // Closing question
209    sections.push("What would you like to work on?".to_string());
210
211    sections.join("\n\n")
212}
213
214/// Build the opening statement based on available context.
215fn build_opening_statement(context: &FollowUpContext) -> String {
216    let branch = &context.git.branch_name;
217    let level = context.richness_level();
218
219    match level {
220        3 => format!(
221            "You're on branch `{}`. I've loaded the spec, session knowledge, and git history.",
222            branch
223        ),
224        2 => {
225            if context.has_spec() {
226                format!(
227                    "You're on branch `{}`. I've loaded the spec and git history.",
228                    branch
229                )
230            } else {
231                format!(
232                    "You're on branch `{}`. I've loaded session knowledge and git history.",
233                    branch
234                )
235            }
236        }
237        _ => format!(
238            "You're on branch `{}`. I've loaded the git history.",
239            branch
240        ),
241    }
242}
243
244/// Build a summary of the spec.
245fn build_spec_summary(spec: &Spec) -> String {
246    let (completed, total) = spec.progress();
247    let status = if spec.all_complete() {
248        "all complete".to_string()
249    } else {
250        format!("{}/{} stories complete", completed, total)
251    };
252
253    format!("**Feature:** {} ({})", spec.project, status)
254}
255
256/// Build a summary of key decisions (topic and choice only).
257fn build_decisions_summary(decisions: &[crate::knowledge::Decision]) -> String {
258    let mut lines = vec!["**Key decisions:**".to_string()];
259
260    // Limit to 5 most recent decisions to keep prompt concise
261    for decision in decisions.iter().take(5) {
262        lines.push(format!("- {}: {}", decision.topic, decision.choice));
263    }
264
265    if decisions.len() > 5 {
266        lines.push(format!("- ...and {} more", decisions.len() - 5));
267    }
268
269    lines.join("\n")
270}
271
272/// Build a summary of files touched, grouped by created/modified.
273fn build_files_summary(story_changes: &[crate::knowledge::StoryChanges]) -> Option<String> {
274    use std::collections::HashSet;
275
276    let mut created: HashSet<&std::path::Path> = HashSet::new();
277    let mut modified: HashSet<&std::path::Path> = HashSet::new();
278
279    for changes in story_changes {
280        for file in &changes.files_created {
281            created.insert(&file.path);
282        }
283        for file in &changes.files_modified {
284            // Don't include in modified if already in created
285            if !created.contains(file.path.as_path()) {
286                modified.insert(&file.path);
287            }
288        }
289    }
290
291    if created.is_empty() && modified.is_empty() {
292        return None;
293    }
294
295    let mut lines = vec!["**Files touched:**".to_string()];
296
297    // Sort and limit files for readability
298    let mut created_vec: Vec<_> = created.iter().collect();
299    created_vec.sort();
300
301    let mut modified_vec: Vec<_> = modified.iter().collect();
302    modified_vec.sort();
303
304    // Show created files (limit to 8)
305    if !created_vec.is_empty() {
306        lines.push("Created:".to_string());
307        for path in created_vec.iter().take(8) {
308            lines.push(format!("- {}", path.display()));
309        }
310        if created_vec.len() > 8 {
311            lines.push(format!("- ...and {} more", created_vec.len() - 8));
312        }
313    }
314
315    // Show modified files (limit to 8)
316    if !modified_vec.is_empty() {
317        lines.push("Modified:".to_string());
318        for path in modified_vec.iter().take(8) {
319            lines.push(format!("- {}", path.display()));
320        }
321        if modified_vec.len() > 8 {
322            lines.push(format!("- ...and {} more", modified_vec.len() - 8));
323        }
324    }
325
326    Some(lines.join("\n"))
327}
328
329/// Build a summary of work completed in previous iterations.
330fn build_work_summaries(summaries: &[String]) -> String {
331    let mut lines = vec!["**Work completed:**".to_string()];
332
333    // Limit to 5 most recent summaries
334    for summary in summaries.iter().take(5) {
335        // Truncate long summaries to first 100 chars
336        let truncated = if summary.len() > 100 {
337            format!("{}...", &summary[..97])
338        } else {
339            summary.clone()
340        };
341        lines.push(format!("- {}", truncated));
342    }
343
344    if summaries.len() > 5 {
345        lines.push(format!("- ...and {} more iterations", summaries.len() - 5));
346    }
347
348    lines.join("\n")
349}
350
351// ============================================================================
352// US-004: Follow-Up Context (Combined Layers)
353// ============================================================================
354
355/// Combined context for follow-up work with Claude.
356///
357/// This struct combines all three context layers:
358/// - Layer 1 (Git): Always present - branch, commits, diff entries
359/// - Layer 2 (Spec): Optional - loaded from session or by branch name
360/// - Layer 3 (Knowledge): Optional - decisions, patterns, files, work summaries
361///
362/// All layers except git are optional and degrade gracefully when unavailable.
363#[derive(Debug, Clone)]
364pub struct FollowUpContext {
365    /// Git context (Layer 1) - always present
366    pub git: GitContext,
367
368    /// Spec loaded from session or by branch name (Layer 2) - optional
369    pub spec: Option<Spec>,
370
371    /// Path to the spec file (if spec was loaded)
372    pub spec_path: Option<PathBuf>,
373
374    /// Project knowledge from the session (Layer 3) - optional
375    pub knowledge: Option<ProjectKnowledge>,
376
377    /// Work summaries collected from all iterations (Layer 3) - optional
378    /// Each summary describes what was accomplished in an iteration.
379    pub work_summaries: Vec<String>,
380
381    /// Session ID if a matching session was found
382    pub session_id: Option<String>,
383}
384
385impl FollowUpContext {
386    /// Check if spec context is available.
387    pub fn has_spec(&self) -> bool {
388        self.spec.is_some()
389    }
390
391    /// Check if session knowledge is available.
392    pub fn has_knowledge(&self) -> bool {
393        self.knowledge.is_some()
394    }
395
396    /// Check if any work summaries were collected.
397    pub fn has_work_summaries(&self) -> bool {
398        !self.work_summaries.is_empty()
399    }
400
401    /// Get the total number of work summaries.
402    pub fn work_summary_count(&self) -> usize {
403        self.work_summaries.len()
404    }
405
406    /// Get context richness level (1-3) based on available layers.
407    ///
408    /// - Level 1: Git only
409    /// - Level 2: Git + Spec
410    /// - Level 3: Git + Spec + Knowledge
411    pub fn richness_level(&self) -> u8 {
412        let mut level = 1; // Git is always present
413        if self.has_spec() {
414            level += 1;
415        }
416        if self.has_knowledge() {
417            level += 1;
418        }
419        level
420    }
421}
422
423/// Load all context layers for follow-up work.
424///
425/// This function gathers context additively:
426/// 1. Git context is always gathered (required)
427/// 2. Spec is loaded from session's spec_json_path if available,
428///    otherwise by matching branch name
429/// 3. If a session is found, project knowledge and work summaries
430///    are extracted from the RunState
431///
432/// # Returns
433/// * `Ok(FollowUpContext)` - Combined context from all available layers
434/// * `Err` - Only if git context gathering fails (fatal)
435pub fn load_follow_up_context() -> Result<FollowUpContext> {
436    // Layer 1: Git context (always required)
437    let git = gather_git_context()?;
438
439    // Try to find a matching session for this branch
440    let state_manager = StateManager::new()?;
441    let session_metadata = state_manager
442        .find_session_for_branch(&git.branch_name)
443        .ok()
444        .flatten();
445
446    let mut spec: Option<Spec> = None;
447    let mut spec_path: Option<PathBuf> = None;
448    let mut knowledge: Option<ProjectKnowledge> = None;
449    let mut work_summaries: Vec<String> = Vec::new();
450    let mut session_id: Option<String> = None;
451
452    if let Some(ref metadata) = session_metadata {
453        session_id = Some(metadata.session_id.clone());
454
455        // Layer 2: Try to load spec from session's spec_json_path first
456        if let Some(ref path) = metadata.spec_json_path {
457            if path.exists() {
458                if let Ok(loaded_spec) = Spec::load(path) {
459                    spec = Some(loaded_spec);
460                    spec_path = Some(path.clone());
461                }
462            }
463        }
464
465        // Layer 3: Load RunState to extract knowledge and work summaries
466        let session_state_manager = StateManager::with_session(metadata.session_id.clone())?;
467        if let Ok(Some(run_state)) = session_state_manager.load_current() {
468            // Extract project knowledge
469            knowledge = Some(run_state.knowledge.clone());
470
471            // Extract work summaries from iterations
472            work_summaries = run_state
473                .iterations
474                .iter()
475                .filter_map(|iter| iter.work_summary.clone())
476                .collect();
477        }
478    }
479
480    // Layer 2 fallback: If spec wasn't loaded from session, try find_spec_for_branch
481    if spec.is_none() {
482        if let Ok(Some((found_spec, found_path))) = find_spec_for_branch(&git.branch_name) {
483            spec = Some(found_spec);
484            spec_path = Some(found_path);
485        }
486    }
487
488    Ok(FollowUpContext {
489        git,
490        spec,
491        spec_path,
492        knowledge,
493        work_summaries,
494        session_id,
495    })
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use crate::git::{DiffEntry, DiffStatus};
502    use crate::knowledge::{Decision, Pattern};
503    use crate::spec::UserStory;
504    use std::path::PathBuf;
505
506    // ========================================================================
507    // GitContext struct tests
508    // ========================================================================
509
510    #[test]
511    fn test_git_context_is_feature_branch_true() {
512        let context = GitContext {
513            branch_name: "feature/improve-command".to_string(),
514            base_branch: "main".to_string(),
515            commits: vec![],
516            diff_entries: vec![],
517            merge_base_commit: None,
518        };
519
520        assert!(context.is_feature_branch());
521    }
522
523    #[test]
524    fn test_git_context_is_feature_branch_false_main() {
525        let context = GitContext {
526            branch_name: "main".to_string(),
527            base_branch: "main".to_string(),
528            commits: vec![],
529            diff_entries: vec![],
530            merge_base_commit: None,
531        };
532
533        assert!(!context.is_feature_branch());
534    }
535
536    #[test]
537    fn test_git_context_is_feature_branch_false_master() {
538        let context = GitContext {
539            branch_name: "master".to_string(),
540            base_branch: "master".to_string(),
541            commits: vec![],
542            diff_entries: vec![],
543            merge_base_commit: None,
544        };
545
546        assert!(!context.is_feature_branch());
547    }
548
549    #[test]
550    fn test_git_context_commit_count() {
551        let context = GitContext {
552            branch_name: "feature/test".to_string(),
553            base_branch: "main".to_string(),
554            commits: vec![
555                crate::git::CommitInfo {
556                    short_hash: "abc1234".to_string(),
557                    full_hash: "abc1234567890".to_string(),
558                    message: "First commit".to_string(),
559                    author: "Test".to_string(),
560                    date: "2024-01-15".to_string(),
561                },
562                crate::git::CommitInfo {
563                    short_hash: "def5678".to_string(),
564                    full_hash: "def5678901234".to_string(),
565                    message: "Second commit".to_string(),
566                    author: "Test".to_string(),
567                    date: "2024-01-16".to_string(),
568                },
569            ],
570            diff_entries: vec![],
571            merge_base_commit: Some("basehash".to_string()),
572        };
573
574        assert_eq!(context.commit_count(), 2);
575    }
576
577    #[test]
578    fn test_git_context_files_changed_count() {
579        let context = GitContext {
580            branch_name: "feature/test".to_string(),
581            base_branch: "main".to_string(),
582            commits: vec![],
583            diff_entries: vec![
584                DiffEntry {
585                    path: PathBuf::from("src/lib.rs"),
586                    additions: 10,
587                    deletions: 5,
588                    status: DiffStatus::Modified,
589                },
590                DiffEntry {
591                    path: PathBuf::from("src/main.rs"),
592                    additions: 20,
593                    deletions: 0,
594                    status: DiffStatus::Added,
595                },
596            ],
597            merge_base_commit: None,
598        };
599
600        assert_eq!(context.files_changed_count(), 2);
601    }
602
603    #[test]
604    fn test_git_context_total_additions() {
605        let context = GitContext {
606            branch_name: "feature/test".to_string(),
607            base_branch: "main".to_string(),
608            commits: vec![],
609            diff_entries: vec![
610                DiffEntry {
611                    path: PathBuf::from("src/lib.rs"),
612                    additions: 10,
613                    deletions: 5,
614                    status: DiffStatus::Modified,
615                },
616                DiffEntry {
617                    path: PathBuf::from("src/main.rs"),
618                    additions: 20,
619                    deletions: 3,
620                    status: DiffStatus::Modified,
621                },
622            ],
623            merge_base_commit: None,
624        };
625
626        assert_eq!(context.total_additions(), 30);
627    }
628
629    #[test]
630    fn test_git_context_total_deletions() {
631        let context = GitContext {
632            branch_name: "feature/test".to_string(),
633            base_branch: "main".to_string(),
634            commits: vec![],
635            diff_entries: vec![
636                DiffEntry {
637                    path: PathBuf::from("src/lib.rs"),
638                    additions: 10,
639                    deletions: 5,
640                    status: DiffStatus::Modified,
641                },
642                DiffEntry {
643                    path: PathBuf::from("src/main.rs"),
644                    additions: 20,
645                    deletions: 3,
646                    status: DiffStatus::Modified,
647                },
648            ],
649            merge_base_commit: None,
650        };
651
652        assert_eq!(context.total_deletions(), 8);
653    }
654
655    #[test]
656    fn test_git_context_empty() {
657        let context = GitContext {
658            branch_name: "feature/empty".to_string(),
659            base_branch: "main".to_string(),
660            commits: vec![],
661            diff_entries: vec![],
662            merge_base_commit: None,
663        };
664
665        assert_eq!(context.commit_count(), 0);
666        assert_eq!(context.files_changed_count(), 0);
667        assert_eq!(context.total_additions(), 0);
668        assert_eq!(context.total_deletions(), 0);
669    }
670
671    #[test]
672    fn test_git_context_clone() {
673        let context = GitContext {
674            branch_name: "feature/test".to_string(),
675            base_branch: "main".to_string(),
676            commits: vec![],
677            diff_entries: vec![],
678            merge_base_commit: Some("abc123".to_string()),
679        };
680
681        let cloned = context.clone();
682        assert_eq!(cloned.branch_name, context.branch_name);
683        assert_eq!(cloned.merge_base_commit, context.merge_base_commit);
684    }
685
686    #[test]
687    fn test_git_context_debug() {
688        let context = GitContext {
689            branch_name: "feature/test".to_string(),
690            base_branch: "main".to_string(),
691            commits: vec![],
692            diff_entries: vec![],
693            merge_base_commit: None,
694        };
695
696        let debug = format!("{:?}", context);
697        assert!(debug.contains("GitContext"));
698        assert!(debug.contains("feature/test"));
699    }
700
701    // ========================================================================
702    // gather_git_context tests (integration-style, run in actual git repo)
703    // ========================================================================
704
705    #[test]
706    fn test_gather_git_context_returns_valid_context() {
707        // This test runs in the autom8 repo, so should succeed
708        let result = gather_git_context();
709        assert!(result.is_ok());
710
711        let context = result.unwrap();
712        // Should have a branch name
713        assert!(!context.branch_name.is_empty());
714        // Should have detected a base branch
715        assert!(!context.base_branch.is_empty());
716    }
717
718    #[test]
719    fn test_gather_git_context_has_base_branch() {
720        let result = gather_git_context();
721        assert!(result.is_ok());
722
723        let context = result.unwrap();
724        // Base branch should be main or master
725        assert!(
726            context.base_branch == "main" || context.base_branch == "master",
727            "Expected 'main' or 'master', got '{}'",
728            context.base_branch
729        );
730    }
731
732    // ========================================================================
733    // FollowUpContext struct tests (US-004)
734    // ========================================================================
735
736    fn make_git_context() -> GitContext {
737        GitContext {
738            branch_name: "feature/test".to_string(),
739            base_branch: "main".to_string(),
740            commits: vec![],
741            diff_entries: vec![],
742            merge_base_commit: None,
743        }
744    }
745
746    fn make_spec() -> Spec {
747        Spec {
748            project: "TestProject".to_string(),
749            branch_name: "feature/test".to_string(),
750            description: "Test description".to_string(),
751            user_stories: vec![UserStory {
752                id: "US-001".to_string(),
753                title: "Test Story".to_string(),
754                description: "Test".to_string(),
755                acceptance_criteria: vec![],
756                priority: 1,
757                passes: false,
758                notes: String::new(),
759            }],
760        }
761    }
762
763    fn make_knowledge() -> ProjectKnowledge {
764        let mut knowledge = ProjectKnowledge::default();
765        knowledge.decisions.push(Decision {
766            story_id: "US-001".to_string(),
767            topic: "Architecture".to_string(),
768            choice: "Use modules".to_string(),
769            rationale: "Better organization".to_string(),
770        });
771        knowledge.patterns.push(Pattern {
772            story_id: "US-001".to_string(),
773            description: "Use Result for errors".to_string(),
774            example_file: None,
775        });
776        knowledge
777    }
778
779    #[test]
780    fn test_follow_up_context_git_only() {
781        let context = FollowUpContext {
782            git: make_git_context(),
783            spec: None,
784            spec_path: None,
785            knowledge: None,
786            work_summaries: vec![],
787            session_id: None,
788        };
789
790        assert!(!context.has_spec());
791        assert!(!context.has_knowledge());
792        assert!(!context.has_work_summaries());
793        assert_eq!(context.richness_level(), 1);
794    }
795
796    #[test]
797    fn test_follow_up_context_with_spec() {
798        let context = FollowUpContext {
799            git: make_git_context(),
800            spec: Some(make_spec()),
801            spec_path: Some(PathBuf::from("/path/to/spec.json")),
802            knowledge: None,
803            work_summaries: vec![],
804            session_id: None,
805        };
806
807        assert!(context.has_spec());
808        assert!(!context.has_knowledge());
809        assert_eq!(context.richness_level(), 2);
810    }
811
812    #[test]
813    fn test_follow_up_context_full() {
814        let context = FollowUpContext {
815            git: make_git_context(),
816            spec: Some(make_spec()),
817            spec_path: Some(PathBuf::from("/path/to/spec.json")),
818            knowledge: Some(make_knowledge()),
819            work_summaries: vec![
820                "Implemented feature A".to_string(),
821                "Fixed bug in module B".to_string(),
822            ],
823            session_id: Some("session-123".to_string()),
824        };
825
826        assert!(context.has_spec());
827        assert!(context.has_knowledge());
828        assert!(context.has_work_summaries());
829        assert_eq!(context.work_summary_count(), 2);
830        assert_eq!(context.richness_level(), 3);
831    }
832
833    #[test]
834    fn test_follow_up_context_richness_level_spec_only() {
835        // Git + Spec = Level 2
836        let context = FollowUpContext {
837            git: make_git_context(),
838            spec: Some(make_spec()),
839            spec_path: None,
840            knowledge: None,
841            work_summaries: vec![],
842            session_id: None,
843        };
844
845        assert_eq!(context.richness_level(), 2);
846    }
847
848    #[test]
849    fn test_follow_up_context_richness_level_knowledge_only() {
850        // Git + Knowledge (no spec) = Level 2
851        let context = FollowUpContext {
852            git: make_git_context(),
853            spec: None,
854            spec_path: None,
855            knowledge: Some(make_knowledge()),
856            work_summaries: vec![],
857            session_id: None,
858        };
859
860        assert_eq!(context.richness_level(), 2);
861    }
862
863    #[test]
864    fn test_follow_up_context_work_summaries_empty() {
865        let context = FollowUpContext {
866            git: make_git_context(),
867            spec: None,
868            spec_path: None,
869            knowledge: None,
870            work_summaries: vec![],
871            session_id: None,
872        };
873
874        assert!(!context.has_work_summaries());
875        assert_eq!(context.work_summary_count(), 0);
876    }
877
878    #[test]
879    fn test_follow_up_context_work_summaries_with_entries() {
880        let context = FollowUpContext {
881            git: make_git_context(),
882            spec: None,
883            spec_path: None,
884            knowledge: None,
885            work_summaries: vec![
886                "Summary 1".to_string(),
887                "Summary 2".to_string(),
888                "Summary 3".to_string(),
889            ],
890            session_id: None,
891        };
892
893        assert!(context.has_work_summaries());
894        assert_eq!(context.work_summary_count(), 3);
895    }
896
897    #[test]
898    fn test_follow_up_context_clone() {
899        let context = FollowUpContext {
900            git: make_git_context(),
901            spec: Some(make_spec()),
902            spec_path: Some(PathBuf::from("/path/to/spec.json")),
903            knowledge: Some(make_knowledge()),
904            work_summaries: vec!["Summary".to_string()],
905            session_id: Some("session-id".to_string()),
906        };
907
908        let cloned = context.clone();
909        assert_eq!(cloned.git.branch_name, context.git.branch_name);
910        assert_eq!(cloned.spec_path, context.spec_path);
911        assert_eq!(cloned.work_summaries.len(), context.work_summaries.len());
912        assert_eq!(cloned.session_id, context.session_id);
913    }
914
915    #[test]
916    fn test_follow_up_context_debug() {
917        let context = FollowUpContext {
918            git: make_git_context(),
919            spec: None,
920            spec_path: None,
921            knowledge: None,
922            work_summaries: vec![],
923            session_id: None,
924        };
925
926        let debug = format!("{:?}", context);
927        assert!(debug.contains("FollowUpContext"));
928        assert!(debug.contains("git"));
929    }
930
931    // ========================================================================
932    // build_improve_prompt tests (US-005)
933    // ========================================================================
934
935    use crate::knowledge::{FileChange, StoryChanges};
936
937    fn make_knowledge_with_files() -> ProjectKnowledge {
938        let mut knowledge = ProjectKnowledge::default();
939        knowledge.decisions.push(Decision {
940            story_id: "US-001".to_string(),
941            topic: "Architecture".to_string(),
942            choice: "Use modules".to_string(),
943            rationale: "Better organization".to_string(),
944        });
945        knowledge.decisions.push(Decision {
946            story_id: "US-002".to_string(),
947            topic: "Error handling".to_string(),
948            choice: "Use thiserror".to_string(),
949            rationale: "Clean error types".to_string(),
950        });
951        knowledge.story_changes.push(StoryChanges {
952            story_id: "US-001".to_string(),
953            files_created: vec![FileChange {
954                path: PathBuf::from("src/new_module.rs"),
955                additions: 100,
956                deletions: 0,
957                purpose: Some("New module".to_string()),
958                key_symbols: vec![],
959            }],
960            files_modified: vec![FileChange {
961                path: PathBuf::from("src/lib.rs"),
962                additions: 5,
963                deletions: 0,
964                purpose: None,
965                key_symbols: vec![],
966            }],
967            files_deleted: vec![],
968            commit_hash: None,
969        });
970        knowledge
971    }
972
973    fn make_complete_spec() -> Spec {
974        Spec {
975            project: "TestProject".to_string(),
976            branch_name: "feature/test".to_string(),
977            description: "Test description".to_string(),
978            user_stories: vec![
979                UserStory {
980                    id: "US-001".to_string(),
981                    title: "Test Story".to_string(),
982                    description: "Test".to_string(),
983                    acceptance_criteria: vec![],
984                    priority: 1,
985                    passes: true,
986                    notes: String::new(),
987                },
988                UserStory {
989                    id: "US-002".to_string(),
990                    title: "Test Story 2".to_string(),
991                    description: "Test 2".to_string(),
992                    acceptance_criteria: vec![],
993                    priority: 2,
994                    passes: true,
995                    notes: String::new(),
996                },
997            ],
998        }
999    }
1000
1001    #[test]
1002    fn test_build_improve_prompt_git_only() {
1003        let context = FollowUpContext {
1004            git: make_git_context(),
1005            spec: None,
1006            spec_path: None,
1007            knowledge: None,
1008            work_summaries: vec![],
1009            session_id: None,
1010        };
1011
1012        let prompt = build_improve_prompt(&context);
1013
1014        // Should contain branch name
1015        assert!(prompt.contains("feature/test"));
1016        // Should mention git history
1017        assert!(prompt.contains("git history"));
1018        // Should end with question
1019        assert!(prompt.contains("What would you like to work on?"));
1020        // Should NOT contain spec or knowledge sections
1021        assert!(!prompt.contains("**Feature:**"));
1022        assert!(!prompt.contains("**Key decisions:**"));
1023    }
1024
1025    #[test]
1026    fn test_build_improve_prompt_with_spec() {
1027        let context = FollowUpContext {
1028            git: make_git_context(),
1029            spec: Some(make_spec()),
1030            spec_path: None,
1031            knowledge: None,
1032            work_summaries: vec![],
1033            session_id: None,
1034        };
1035
1036        let prompt = build_improve_prompt(&context);
1037
1038        // Should contain branch and spec info
1039        assert!(prompt.contains("feature/test"));
1040        assert!(prompt.contains("**Feature:**"));
1041        assert!(prompt.contains("TestProject"));
1042        // Should show story count (0/1 complete)
1043        assert!(prompt.contains("0/1 stories complete"));
1044        // Should end with question
1045        assert!(prompt.contains("What would you like to work on?"));
1046    }
1047
1048    #[test]
1049    fn test_build_improve_prompt_with_complete_spec() {
1050        let context = FollowUpContext {
1051            git: make_git_context(),
1052            spec: Some(make_complete_spec()),
1053            spec_path: None,
1054            knowledge: None,
1055            work_summaries: vec![],
1056            session_id: None,
1057        };
1058
1059        let prompt = build_improve_prompt(&context);
1060
1061        // Should show "all complete" status
1062        assert!(prompt.contains("all complete"));
1063    }
1064
1065    #[test]
1066    fn test_build_improve_prompt_with_decisions() {
1067        let context = FollowUpContext {
1068            git: make_git_context(),
1069            spec: None,
1070            spec_path: None,
1071            knowledge: Some(make_knowledge_with_files()),
1072            work_summaries: vec![],
1073            session_id: None,
1074        };
1075
1076        let prompt = build_improve_prompt(&context);
1077
1078        // Should contain decisions section
1079        assert!(prompt.contains("**Key decisions:**"));
1080        // Should contain topic and choice
1081        assert!(prompt.contains("Architecture: Use modules"));
1082        assert!(prompt.contains("Error handling: Use thiserror"));
1083    }
1084
1085    #[test]
1086    fn test_build_improve_prompt_with_files() {
1087        let context = FollowUpContext {
1088            git: make_git_context(),
1089            spec: None,
1090            spec_path: None,
1091            knowledge: Some(make_knowledge_with_files()),
1092            work_summaries: vec![],
1093            session_id: None,
1094        };
1095
1096        let prompt = build_improve_prompt(&context);
1097
1098        // Should contain files section
1099        assert!(prompt.contains("**Files touched:**"));
1100        // Should show created and modified separately
1101        assert!(prompt.contains("Created:"));
1102        assert!(prompt.contains("src/new_module.rs"));
1103        assert!(prompt.contains("Modified:"));
1104        assert!(prompt.contains("src/lib.rs"));
1105    }
1106
1107    #[test]
1108    fn test_build_improve_prompt_with_work_summaries() {
1109        let context = FollowUpContext {
1110            git: make_git_context(),
1111            spec: None,
1112            spec_path: None,
1113            knowledge: None,
1114            work_summaries: vec![
1115                "Implemented user authentication".to_string(),
1116                "Fixed login validation bug".to_string(),
1117            ],
1118            session_id: None,
1119        };
1120
1121        let prompt = build_improve_prompt(&context);
1122
1123        // Should contain work summaries section
1124        assert!(prompt.contains("**Work completed:**"));
1125        assert!(prompt.contains("Implemented user authentication"));
1126        assert!(prompt.contains("Fixed login validation bug"));
1127    }
1128
1129    #[test]
1130    fn test_build_improve_prompt_full_context() {
1131        let context = FollowUpContext {
1132            git: make_git_context(),
1133            spec: Some(make_spec()),
1134            spec_path: None,
1135            knowledge: Some(make_knowledge_with_files()),
1136            work_summaries: vec!["Completed initial setup".to_string()],
1137            session_id: Some("session-123".to_string()),
1138        };
1139
1140        let prompt = build_improve_prompt(&context);
1141
1142        // Should mention all layers loaded
1143        assert!(prompt.contains("spec, session knowledge, and git history"));
1144        // Should have all sections
1145        assert!(prompt.contains("**Feature:**"));
1146        assert!(prompt.contains("**Key decisions:**"));
1147        assert!(prompt.contains("**Files touched:**"));
1148        assert!(prompt.contains("**Work completed:**"));
1149        // Should end with question
1150        assert!(prompt.ends_with("What would you like to work on?"));
1151    }
1152
1153    #[test]
1154    fn test_build_improve_prompt_limits_decisions() {
1155        let mut knowledge = ProjectKnowledge::default();
1156        // Add 7 decisions
1157        for i in 1..=7 {
1158            knowledge.decisions.push(Decision {
1159                story_id: format!("US-{:03}", i),
1160                topic: format!("Topic {}", i),
1161                choice: format!("Choice {}", i),
1162                rationale: "Rationale".to_string(),
1163            });
1164        }
1165
1166        let context = FollowUpContext {
1167            git: make_git_context(),
1168            spec: None,
1169            spec_path: None,
1170            knowledge: Some(knowledge),
1171            work_summaries: vec![],
1172            session_id: None,
1173        };
1174
1175        let prompt = build_improve_prompt(&context);
1176
1177        // Should show first 5 and indicate more
1178        assert!(prompt.contains("Topic 1: Choice 1"));
1179        assert!(prompt.contains("Topic 5: Choice 5"));
1180        assert!(prompt.contains("...and 2 more"));
1181        // Should NOT show Topic 6 or 7 directly
1182        assert!(!prompt.contains("Topic 6: Choice 6"));
1183    }
1184
1185    #[test]
1186    fn test_build_improve_prompt_truncates_long_summaries() {
1187        let long_summary = "A".repeat(150);
1188        let context = FollowUpContext {
1189            git: make_git_context(),
1190            spec: None,
1191            spec_path: None,
1192            knowledge: None,
1193            work_summaries: vec![long_summary],
1194            session_id: None,
1195        };
1196
1197        let prompt = build_improve_prompt(&context);
1198
1199        // Should contain truncated summary with ellipsis
1200        assert!(prompt.contains("..."));
1201        // Should not contain the full 150-char string
1202        assert!(!prompt.contains(&"A".repeat(150)));
1203    }
1204
1205    #[test]
1206    fn test_build_improve_prompt_limits_work_summaries() {
1207        let summaries: Vec<String> = (1..=8).map(|i| format!("Summary {}", i)).collect();
1208
1209        let context = FollowUpContext {
1210            git: make_git_context(),
1211            spec: None,
1212            spec_path: None,
1213            knowledge: None,
1214            work_summaries: summaries,
1215            session_id: None,
1216        };
1217
1218        let prompt = build_improve_prompt(&context);
1219
1220        // Should show first 5 and indicate more
1221        assert!(prompt.contains("Summary 1"));
1222        assert!(prompt.contains("Summary 5"));
1223        assert!(prompt.contains("...and 3 more iterations"));
1224    }
1225
1226    #[test]
1227    fn test_build_improve_prompt_empty_knowledge_no_files_section() {
1228        let mut knowledge = ProjectKnowledge::default();
1229        knowledge.decisions.push(Decision {
1230            story_id: "US-001".to_string(),
1231            topic: "Test".to_string(),
1232            choice: "Test".to_string(),
1233            rationale: "Test".to_string(),
1234        });
1235        // No story_changes
1236
1237        let context = FollowUpContext {
1238            git: make_git_context(),
1239            spec: None,
1240            spec_path: None,
1241            knowledge: Some(knowledge),
1242            work_summaries: vec![],
1243            session_id: None,
1244        };
1245
1246        let prompt = build_improve_prompt(&context);
1247
1248        // Should have decisions but no files section
1249        assert!(prompt.contains("**Key decisions:**"));
1250        assert!(!prompt.contains("**Files touched:**"));
1251    }
1252
1253    #[test]
1254    fn test_build_improve_prompt_opening_level_1() {
1255        let context = FollowUpContext {
1256            git: make_git_context(),
1257            spec: None,
1258            spec_path: None,
1259            knowledge: None,
1260            work_summaries: vec![],
1261            session_id: None,
1262        };
1263
1264        let prompt = build_improve_prompt(&context);
1265        assert!(prompt.contains("I've loaded the git history"));
1266    }
1267
1268    #[test]
1269    fn test_build_improve_prompt_opening_level_2_with_spec() {
1270        let context = FollowUpContext {
1271            git: make_git_context(),
1272            spec: Some(make_spec()),
1273            spec_path: None,
1274            knowledge: None,
1275            work_summaries: vec![],
1276            session_id: None,
1277        };
1278
1279        let prompt = build_improve_prompt(&context);
1280        assert!(prompt.contains("I've loaded the spec and git history"));
1281    }
1282
1283    #[test]
1284    fn test_build_improve_prompt_opening_level_2_with_knowledge() {
1285        let context = FollowUpContext {
1286            git: make_git_context(),
1287            spec: None,
1288            spec_path: None,
1289            knowledge: Some(make_knowledge()),
1290            work_summaries: vec![],
1291            session_id: None,
1292        };
1293
1294        let prompt = build_improve_prompt(&context);
1295        assert!(prompt.contains("I've loaded session knowledge and git history"));
1296    }
1297
1298    #[test]
1299    fn test_build_improve_prompt_opening_level_3() {
1300        let context = FollowUpContext {
1301            git: make_git_context(),
1302            spec: Some(make_spec()),
1303            spec_path: None,
1304            knowledge: Some(make_knowledge()),
1305            work_summaries: vec![],
1306            session_id: None,
1307        };
1308
1309        let prompt = build_improve_prompt(&context);
1310        assert!(prompt.contains("I've loaded the spec, session knowledge, and git history"));
1311    }
1312
1313    // ========================================================================
1314    // load_follow_up_context tests (integration-style)
1315    // ========================================================================
1316
1317    #[test]
1318    fn test_load_follow_up_context_succeeds() {
1319        // This runs in the autom8 repo, so should succeed
1320        let result = load_follow_up_context();
1321        assert!(result.is_ok());
1322
1323        let context = result.unwrap();
1324        // Git context should always be present
1325        assert!(!context.git.branch_name.is_empty());
1326        // Richness level should be at least 1 (git)
1327        assert!(context.richness_level() >= 1);
1328    }
1329
1330    #[test]
1331    fn test_load_follow_up_context_git_always_present() {
1332        let result = load_follow_up_context();
1333        assert!(result.is_ok());
1334
1335        let context = result.unwrap();
1336        // All required git fields should be populated
1337        assert!(!context.git.branch_name.is_empty());
1338        assert!(!context.git.base_branch.is_empty());
1339        // commits and diff_entries may be empty but should not panic
1340        let _ = context.git.commit_count();
1341        let _ = context.git.files_changed_count();
1342    }
1343}