Skip to main content

cship/
explain.rs

1//! `cship explain` subcommand — shows each native module's rendered value and config source.
2
3const SAMPLE_CONTEXT: &str = include_str!("sample_context.json");
4const SAMPLE_CONTEXT_PATH: &str = ".config/cship/sample-context.json";
5
6/// Run the explain subcommand and return the formatted output as a String.
7/// `main.rs` is the sole stdout writer — this function only builds the string.
8pub fn run(config_override: Option<&std::path::Path>) -> String {
9    let (ctx, creation_notes) = load_context();
10    let workspace_dir = ctx
11        .workspace
12        .as_ref()
13        .and_then(|w| w.current_dir.as_deref());
14    let result = crate::config::load_with_source(config_override, workspace_dir);
15    let cfg = result.config;
16    let source = result.source;
17
18    // Pre-compute module column width from actual names so long names never overflow.
19    let mod_w = crate::modules::ALL_NATIVE_MODULES
20        .iter()
21        .map(|s| s.len())
22        .max()
23        .unwrap_or(40)
24        + 1;
25    const VAL_W: usize = 30;
26    const CFG_W: usize = 22; // "[cship.context_window]" = 22 chars
27
28    let mut lines = Vec::new();
29    lines.push(format!("cship explain — using config: {source}"));
30    lines.push(String::new());
31    lines.push(format!(
32        "{:<mod_w$} {:<VAL_W$} {}",
33        "Module", "Value", "Config"
34    ));
35    lines.push("─".repeat(mod_w + 1 + VAL_W + 1 + CFG_W));
36
37    let mut none_modules: Vec<(&str, String, String)> = Vec::new();
38
39    for &module_name in crate::modules::ALL_NATIVE_MODULES {
40        let value = crate::modules::render_module(module_name, &ctx, &cfg);
41        let display_value = match &value {
42            Some(s) => crate::ansi::strip_ansi(s),
43            None => "(empty)".to_string(),
44        };
45        let config_col = config_section_for(module_name, &cfg);
46        // Truncate display_value to VAL_W chars so long path values don't push Config column right.
47        // Use char-aware counting to avoid splitting multi-byte characters (e.g. ░, █).
48        let display_value = if display_value.chars().count() > VAL_W {
49            let truncated: String = display_value.chars().take(VAL_W - 1).collect();
50            format!("{truncated}…")
51        } else {
52            display_value
53        };
54
55        let display_name = if value.is_none() {
56            let (error, remediation) = error_hint_for(module_name, &ctx, &cfg);
57            none_modules.push((module_name, error, remediation));
58            format!("⚠ {module_name}")
59        } else {
60            module_name.to_string()
61        };
62
63        lines.push(format!(
64            "{:<mod_w$} {:<VAL_W$} {}",
65            display_name, display_value, config_col
66        ));
67    }
68
69    // Hints section for modules that returned None
70    if !none_modules.is_empty() {
71        lines.push(String::new());
72        lines.push(format!(
73            "─── Hints for modules showing (empty) {}",
74            "─".repeat(34)
75        ));
76        for (name, error, remediation) in &none_modules {
77            lines.push(String::new());
78            lines.push(format!("⚠ {name}"));
79            lines.push(format!("  Error: {error}"));
80            lines.push(format!("  Hint:  {remediation}"));
81        }
82    }
83
84    // Sample file creation notes
85    if !creation_notes.is_empty() {
86        lines.push(String::new());
87        lines.push(format!("─── Note {}", "─".repeat(59)));
88        lines.push(String::new());
89        for note in &creation_notes {
90            lines.push(format!("i  {note}"));
91        }
92    }
93
94    lines.join("\n")
95}
96
97fn load_context() -> (crate::context::Context, Vec<String>) {
98    use std::io::IsTerminal;
99    let mut notes = Vec::new();
100
101    // 1. If stdin is not a TTY, read from stdin (same path as main render pipeline)
102    if !std::io::stdin().is_terminal() {
103        match crate::context::from_reader(std::io::stdin()) {
104            Ok(ctx) => return (ctx, notes),
105            Err(e) => {
106                tracing::warn!(
107                    "cship explain: failed to parse stdin JSON: {e} — falling back to sample context"
108                );
109            }
110        }
111    }
112
113    // 2. Try ~/.config/cship/sample-context.json
114    if let Ok(home) = std::env::var("HOME") {
115        let sample_path = std::path::Path::new(&home).join(SAMPLE_CONTEXT_PATH);
116        if sample_path.exists() {
117            if let Ok(content) = std::fs::read_to_string(&sample_path)
118                && let Ok(ctx) = serde_json::from_str(&content)
119            {
120                return (ctx, notes);
121            }
122        } else {
123            // File does not exist — create it from the embedded template
124            if let Some(parent) = sample_path.parent()
125                && std::fs::create_dir_all(parent).is_ok()
126                && std::fs::write(&sample_path, SAMPLE_CONTEXT).is_ok()
127            {
128                notes.push(format!(
129                    "Created sample context at {}. Edit it to test different threshold scenarios.",
130                    sample_path.display()
131                ));
132            }
133        }
134    }
135
136    // 3. Use embedded template (always succeeds — compile-time guarantee)
137    let ctx = serde_json::from_str(SAMPLE_CONTEXT)
138        .expect("embedded sample_context.json must be valid — this is a compile-time guarantee");
139    (ctx, notes)
140}
141
142fn is_disabled(name: &str, cfg: &crate::config::CshipConfig) -> bool {
143    let top = name.strip_prefix("cship.").unwrap_or(name);
144    let segment = top.split('.').next().unwrap_or(top);
145    match segment {
146        "model" => cfg.model.as_ref().and_then(|m| m.disabled).unwrap_or(false),
147        "cost" => cfg.cost.as_ref().and_then(|m| m.disabled).unwrap_or(false),
148        "context_bar" => cfg
149            .context_bar
150            .as_ref()
151            .and_then(|m| m.disabled)
152            .unwrap_or(false),
153        "context_window" => cfg
154            .context_window
155            .as_ref()
156            .and_then(|m| m.disabled)
157            .unwrap_or(false),
158        "vim" => cfg.vim.as_ref().and_then(|m| m.disabled).unwrap_or(false),
159        "agent" => cfg.agent.as_ref().and_then(|m| m.disabled).unwrap_or(false),
160        "cwd" | "session_id" | "transcript_path" | "version" | "output_style" => cfg
161            .session
162            .as_ref()
163            .and_then(|m| m.disabled)
164            .unwrap_or(false),
165        "workspace" => cfg
166            .workspace
167            .as_ref()
168            .and_then(|m| m.disabled)
169            .unwrap_or(false),
170        "usage_limits" => cfg
171            .usage_limits
172            .as_ref()
173            .and_then(|m| m.disabled)
174            .unwrap_or(false),
175        _ => false,
176    }
177}
178
179fn error_hint_for(
180    name: &str,
181    _ctx: &crate::context::Context,
182    cfg: &crate::config::CshipConfig,
183) -> (String, String) {
184    let top = name.strip_prefix("cship.").unwrap_or(name);
185    let segment = top.split('.').next().unwrap_or(top);
186    if is_disabled(name, cfg) {
187        return (
188            "module explicitly disabled in config".into(),
189            format!(
190                "Remove `disabled = true` from the [cship.{segment}] section in starship.toml."
191            ),
192        );
193    }
194    match segment {
195        "model" => (
196            "model data absent from Claude Code context".into(),
197            "Ensure Claude Code is running and cship is invoked via the \"statusline\" key in ~/.claude/settings.json.".into(),
198        ),
199        "cost" => (
200            "cost data absent from Claude Code context".into(),
201            "Ensure Claude Code is running and cship is invoked via the \"statusline\" key in ~/.claude/settings.json.".into(),
202        ),
203        "context_bar" | "context_window" => (
204            "context_window data absent from Claude Code context (may be absent early in a session)".into(),
205            "Ensure Claude Code is running. Context window data appears after the first API response.".into(),
206        ),
207        "vim" => (
208            "vim mode data absent — vim mode may not be enabled".into(),
209            "Enable vim mode: add \"vim.mode\": \"INSERT\" to ~/.claude/settings.json.".into(),
210        ),
211        "agent" => (
212            "agent data absent — no agent session active".into(),
213            "Agent data is only present during agent sessions. Start an agent session or use the --agent flag.".into(),
214        ),
215        "cwd" | "session_id" | "transcript_path" | "version" | "output_style" => (
216            "session field absent from Claude Code context".into(),
217            "Ensure Claude Code is running and cship is invoked via the \"statusline\" key in ~/.claude/settings.json.".into(),
218        ),
219        "workspace" => (
220            "workspace data absent from Claude Code context".into(),
221            "Ensure Claude Code is running and cship is invoked via the \"statusline\" key in ~/.claude/settings.json.".into(),
222        ),
223        "usage_limits" => {
224            // Probe credential state to distinguish missing token from expired token.
225            // NOTE: This arm spawns a subprocess or reads a file — acceptable for the
226            // interactive `cship explain` command but must NOT be called from the
227            // rendering hot path (main.rs pipeline).
228            match crate::platform::get_oauth_token() {
229                Err(msg) if msg.contains("credentials not found") => (
230                    "usage_limits returned no data — no Claude Code credential found".into(),
231                    "Authenticate by opening Claude Code and completing the login flow, then run `cship explain` again.".into(),
232                ),
233                Ok(_) => (
234                    "usage_limits returned no data — credential present but API fetch failed".into(),
235                    "Your Claude Code token may have expired. Re-authenticate by opening Claude Code and completing the login flow, then run `cship explain` again.".into(),
236                ),
237                Err(_) => (
238                    "usage_limits returned no data — credential appears malformed or tool unavailable".into(),
239                    "Re-authenticate by opening Claude Code and completing the login flow, then run `cship explain` again.".into(),
240                ),
241            }
242        }
243        _ => (
244            "module returned no value".into(),
245            "Check cship configuration and ensure Claude Code is running.".into(),
246        ),
247    }
248}
249
250fn config_section_for(module_name: &str, cfg: &crate::config::CshipConfig) -> &'static str {
251    let top = module_name.strip_prefix("cship.").unwrap_or(module_name);
252    let segment = top.split('.').next().unwrap_or(top);
253    match segment {
254        "model" if cfg.model.is_some() => "[cship.model]",
255        "cost" if cfg.cost.is_some() => "[cship.cost]",
256        "context_bar" if cfg.context_bar.is_some() => "[cship.context_bar]",
257        "context_window" if cfg.context_window.is_some() => "[cship.context_window]",
258        "vim" if cfg.vim.is_some() => "[cship.vim]",
259        "agent" if cfg.agent.is_some() => "[cship.agent]",
260        "cwd" | "session_id" | "transcript_path" | "version" | "output_style"
261            if cfg.session.is_some() =>
262        {
263            "[cship.session]"
264        }
265        "workspace" if cfg.workspace.is_some() => "[cship.workspace]",
266        "usage_limits" if cfg.usage_limits.is_some() => "[cship.usage_limits]",
267        _ => "(default)",
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::config::{CshipConfig, ModelConfig};
275    use crate::context::{Context, Model};
276
277    #[test]
278    fn test_run_returns_header_with_using_config() {
279        let output = run(None);
280        assert!(
281            output.contains("using config:"),
282            "expected 'using config:' in output: {output}"
283        );
284    }
285
286    #[test]
287    fn test_run_contains_all_module_names() {
288        let output = run(None);
289        assert!(
290            output.contains("cship.model"),
291            "expected 'cship.model' in output"
292        );
293        assert!(
294            output.contains("cship.cost"),
295            "expected 'cship.cost' in output"
296        );
297        assert!(
298            output.contains("cship.context_bar"),
299            "expected 'cship.context_bar' in output"
300        );
301        assert!(
302            output.contains("cship.vim"),
303            "expected 'cship.vim' in output"
304        );
305    }
306
307    #[test]
308    fn test_strip_ansi_removes_escape_codes() {
309        let styled = "\x1b[1;32mSonnet\x1b[0m";
310        assert_eq!(crate::ansi::strip_ansi(styled), "Sonnet");
311    }
312
313    #[test]
314    fn test_strip_ansi_leaves_plain_text_unchanged() {
315        assert_eq!(crate::ansi::strip_ansi("plain text"), "plain text");
316    }
317
318    #[test]
319    fn test_config_section_for_model_with_config() {
320        let mut cfg = CshipConfig::default();
321        cfg.model = Some(crate::config::ModelConfig::default());
322        assert_eq!(config_section_for("cship.model", &cfg), "[cship.model]");
323    }
324
325    #[test]
326    fn test_config_section_for_model_without_config() {
327        let cfg = CshipConfig::default();
328        assert_eq!(config_section_for("cship.model", &cfg), "(default)");
329    }
330
331    #[test]
332    fn test_load_context_embedded_fallback_is_valid() {
333        let ctx: Result<Context, _> = serde_json::from_str(SAMPLE_CONTEXT);
334        assert!(
335            ctx.is_ok(),
336            "embedded sample_context.json must parse as Context"
337        );
338    }
339
340    #[test]
341    fn test_run_with_config_override_does_not_panic() {
342        let bad_path = Some(std::path::PathBuf::from("/nonexistent/path.toml"));
343        let output = run(bad_path.as_deref());
344        assert!(output.contains("using config:"));
345    }
346
347    #[test]
348    fn test_load_with_source_respects_workspace_dir() {
349        // Verify that load_with_source accepts workspace_dir parameter (H1 fix)
350        let result = crate::config::load_with_source(None, Some("/nonexistent/dir"));
351        // Should fall through to global (starship.toml or cship.toml) or default without panicking
352        assert!(
353            matches!(
354                result.source,
355                crate::config::ConfigSource::Global(_)
356                    | crate::config::ConfigSource::DedicatedFile(_)
357                    | crate::config::ConfigSource::Default
358            ),
359            "expected Global, DedicatedFile, or Default source for nonexistent workspace dir"
360        );
361    }
362
363    #[test]
364    fn test_run_output_shows_sample_model_value() {
365        // The embedded sample_context.json has model.display_name = "Sonnet"
366        let ctx: Context = serde_json::from_str(SAMPLE_CONTEXT).unwrap();
367        let cfg = CshipConfig::default();
368        let value = crate::modules::render_module("cship.model", &ctx, &cfg);
369        assert!(value.is_some());
370        let stripped = crate::ansi::strip_ansi(&value.unwrap());
371        assert!(
372            stripped.contains("Sonnet"),
373            "expected Sonnet in: {stripped}"
374        );
375    }
376
377    #[test]
378    fn test_run_with_valid_context_shows_model_in_explain_column() {
379        let model_ctx = Context {
380            model: Some(Model {
381                display_name: Some("TestModel".to_string()),
382                ..Default::default()
383            }),
384            ..Default::default()
385        };
386        let cfg = CshipConfig::default();
387        let value = crate::modules::render_module("cship.model", &model_ctx, &cfg);
388        let stripped = crate::ansi::strip_ansi(&value.unwrap_or_default());
389        assert!(stripped.contains("TestModel"));
390    }
391
392    #[test]
393    fn test_run_shows_warning_indicator_for_none_module() {
394        // run() loads the embedded sample context — cship.context_window.exceeds_200k
395        // returns None because sample context value is below 200k threshold.
396        let output = run(None);
397        assert!(
398            output.contains("⚠ cship.context_window.exceeds_200k"),
399            "expected '⚠ cship.context_window.exceeds_200k' in output: {output}"
400        );
401    }
402
403    #[test]
404    fn test_run_shows_hint_section_for_none_module() {
405        let output = run(None);
406        assert!(
407            output.contains("Hints for modules"),
408            "expected hints section in output: {output}"
409        );
410    }
411
412    #[test]
413    fn test_run_shows_error_reason_in_hint() {
414        let output = run(None);
415        // model data absent hint should appear since sample context has model data,
416        // but other modules like vim will be absent
417        assert!(
418            output.contains("absent"),
419            "expected 'absent' in hint output: {output}"
420        );
421    }
422
423    #[test]
424    fn test_error_hint_for_disabled_module_returns_disabled_text() {
425        let mut cfg = CshipConfig::default();
426        cfg.model = Some(ModelConfig {
427            disabled: Some(true),
428            ..Default::default()
429        });
430        let ctx = Context {
431            model: Some(Model {
432                display_name: Some("Sonnet".to_string()),
433                ..Default::default()
434            }),
435            ..Default::default()
436        };
437        // Even with model data present, disabled=true makes it return None
438        let value = crate::modules::render_module("cship.model", &ctx, &cfg);
439        assert!(value.is_none(), "disabled module must return None");
440        let (error, remediation) = error_hint_for("cship.model", &ctx, &cfg);
441        assert!(
442            error.contains("disabled"),
443            "expected 'disabled' in error hint: {error}"
444        );
445        assert!(
446            remediation.contains("[cship.model]"),
447            "expected specific section '[cship.model]' in remediation: {remediation}"
448        );
449    }
450
451    #[test]
452    fn test_is_disabled_returns_true_for_disabled_model() {
453        let mut cfg = CshipConfig::default();
454        cfg.model = Some(ModelConfig {
455            disabled: Some(true),
456            ..Default::default()
457        });
458        assert!(
459            is_disabled("cship.model", &cfg),
460            "is_disabled should return true when model.disabled = Some(true)"
461        );
462    }
463
464    #[test]
465    fn test_is_disabled_returns_false_for_enabled_model() {
466        let cfg = CshipConfig::default();
467        assert!(
468            !is_disabled("cship.model", &cfg),
469            "is_disabled should return false when model config is absent"
470        );
471    }
472
473    // Tests for the usage_limits credential-aware hint branches (TD3).
474    // The exact branch taken depends on the test environment's credential state.
475    // In CI (no credential stored), the "not found" branch fires.
476    // All three tests verify the returned tuple is non-empty and well-formed.
477
478    #[test]
479    fn test_error_hint_usage_limits_returns_non_empty_tuple() {
480        let cfg = CshipConfig::default();
481        let ctx = crate::context::Context::default();
482        let (error, remediation) = error_hint_for("usage_limits", &ctx, &cfg);
483        assert!(
484            !error.is_empty(),
485            "usage_limits error hint must be non-empty"
486        );
487        assert!(
488            !remediation.is_empty(),
489            "usage_limits remediation hint must be non-empty"
490        );
491    }
492
493    #[test]
494    fn test_error_hint_usage_limits_contains_usage_limits_in_error() {
495        let cfg = CshipConfig::default();
496        let ctx = crate::context::Context::default();
497        let (error, _) = error_hint_for("usage_limits", &ctx, &cfg);
498        assert!(
499            error.contains("usage_limits"),
500            "error should mention 'usage_limits', got: {error}"
501        );
502    }
503
504    #[test]
505    fn test_error_hint_usage_limits_matches_valid_branch() {
506        // The exact branch depends on the environment's credential state.
507        // Instead of vacuously skipping assertions, we always verify the
508        // result matches ONE of the three valid branch patterns.
509        let cfg = CshipConfig::default();
510        let ctx = crate::context::Context::default();
511        let (error, remediation) = error_hint_for("usage_limits", &ctx, &cfg);
512
513        let is_no_credential = error.contains("no Claude Code credential found");
514        let is_token_present = error.contains("credential present but API fetch failed");
515        let is_malformed = error.contains("credential appears malformed");
516
517        assert!(
518            is_no_credential || is_token_present || is_malformed,
519            "error must match one of the three hint branches, got: {error}"
520        );
521        // All branches include a re-authentication instruction.
522        assert!(
523            remediation.contains("login flow"),
524            "remediation must include login flow instruction, got: {remediation}"
525        );
526    }
527}