1const SAMPLE_CONTEXT: &str = include_str!("sample_context.json");
4const SAMPLE_CONTEXT_PATH: &str = ".config/cship/sample-context.json";
5
6pub 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 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; 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 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 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 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 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 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 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 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 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 let result = crate::config::load_with_source(None, Some("/nonexistent/dir"));
351 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 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 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 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 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 #[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 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 assert!(
523 remediation.contains("login flow"),
524 "remediation must include login flow instruction, got: {remediation}"
525 );
526 }
527}