Skip to main content

oy/
cli.rs

1// === config ===
2pub(crate) mod config {
3    use crate::tools::{Approval, ToolPolicy};
4    use anyhow::{Context, Result, bail};
5    use chrono::Utc;
6    use dirs::config_dir;
7    use serde::{Deserialize, Serialize};
8    use std::env;
9    use std::fs;
10    use std::io::{IsTerminal as _, Write as _};
11    use std::path::{Path, PathBuf};
12
13    #[derive(Debug, Clone, Serialize, Deserialize, Default)]
14    pub struct SavedModelConfig {
15        pub model: Option<String>,
16        pub shim: Option<String>,
17    }
18
19    #[derive(Debug, Clone, Serialize, Deserialize)]
20    pub struct SessionFile {
21        pub model: String,
22        pub saved_at: String,
23        #[serde(default)]
24        pub workspace_root: Option<PathBuf>,
25        pub transcript: serde_json::Value,
26        #[serde(default)]
27        pub todos: Vec<crate::tools::TodoItem>,
28    }
29
30    #[derive(Debug, Clone, Copy)]
31    pub struct ContextConfig {
32        pub limit_tokens: usize,
33        pub output_reserve_tokens: usize,
34        pub safety_reserve_tokens: usize,
35        pub trigger_ratio: f64,
36        pub recent_messages: usize,
37        pub tool_output_tokens: usize,
38        pub summary_tokens: usize,
39    }
40
41    impl ContextConfig {
42        pub fn input_budget_tokens(self) -> usize {
43            self.limit_tokens
44                .saturating_sub(self.output_reserve_tokens)
45                .saturating_sub(self.safety_reserve_tokens)
46                .max(1)
47        }
48
49        pub fn trigger_tokens(self) -> usize {
50            ((self.input_budget_tokens() as f64) * self.trigger_ratio) as usize
51        }
52    }
53
54    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
55    pub enum SafetyMode {
56        Default,
57        Plan,
58        AutoEdits,
59        AutoAll,
60    }
61
62    impl SafetyMode {
63        pub fn parse(value: &str) -> Result<Self> {
64            match value.trim().to_ascii_lowercase().replace('_', "-").as_str() {
65                "" | "default" | "ask" => Ok(Self::Default),
66                "plan" | "read-only" | "readonly" | "read" => Ok(Self::Plan),
67                "accept-edits" | "edit" | "edits" | "auto-edits" | "write" => Ok(Self::AutoEdits),
68                "auto-approve" | "auto" | "yolo" => Ok(Self::AutoAll),
69                other => bail!("Unknown mode `{other}`. Available: plan, ask, edit, auto"),
70            }
71        }
72
73        pub fn name(self) -> &'static str {
74            match self {
75                Self::Default => "default",
76                Self::Plan => "plan",
77                Self::AutoEdits => "accept-edits",
78                Self::AutoAll => "auto-approve",
79            }
80        }
81
82        fn system_prompt_suffix(self) -> &'static str {
83            match self {
84                Self::Default => "",
85                Self::Plan => PLAN_SYSTEM,
86                Self::AutoEdits => ACCEPT_EDITS_SYSTEM,
87                Self::AutoAll => AUTO_APPROVE_SYSTEM,
88            }
89        }
90
91        fn policy(self) -> ToolPolicy {
92            match self {
93                Self::Plan => ToolPolicy::read_only(),
94                Self::Default => ToolPolicy {
95                    read_only: false,
96                    files_write: Approval::Ask,
97                    shell: Approval::Ask,
98                    network: true,
99                },
100                Self::AutoEdits => ToolPolicy {
101                    read_only: false,
102                    files_write: Approval::Auto,
103                    shell: Approval::Ask,
104                    network: true,
105                },
106                Self::AutoAll => ToolPolicy {
107                    read_only: false,
108                    files_write: Approval::Auto,
109                    shell: Approval::Auto,
110                    network: true,
111                },
112            }
113        }
114    }
115
116    const DEFAULT_CONFIG_DIR_NAME: &str = "oy-rust";
117
118    const BASE_SYSTEM: &str = r#"You are oy, a coding CLI with tools.
119Optimize for the human reviewing your work: be terse, evidence-first, and explicit about changed files/commands.
120Follow the user's output constraints exactly.
121Work inspect → edit → verify. Use the cheapest sufficient tool:
1221. `list` for discovery.
1232. `search` for symbols, paths, and strings.
1243. `read` only narrow file slices you need.
1254. `replace` for surgical edits.
1265. `bash` only when file tools are insufficient or when you must run/check something.
127Batch independent reads/searches. Stop when enough evidence exists.
128Prefer small, boring, idiomatic, functional, testable code with explicit data flow.
129For security-sensitive work, name the trust boundary, validate near it, fail closed, and add focused tests.
130Do not add file, process, network, credential, or persistence capability unless necessary.
131For 3+ step work, keep a short in-memory todo; persist `TODO.md` only on explicit request or quit prompt.
132Use `webfetch` for public docs/API research when useful; prefer it over guessing.
133Tool arguments are schemas, not prose: use documented names, numeric `limit`/`offset`/timeouts, and `mode=literal` for exact search/replace when regex metacharacters are not intended.
134Manage context aggressively: keep only key facts and paths. Prefer narrow `path`, `offset`, `limit`, and `exclude`; use `sloc` if you need a repo-size snapshot.
135Before mutating files or running commands, state the next action briefly. After finishing, report changed files and checks.
136When context gets long, compress to the plan, key evidence, and next action. If blocked, say what you tried and the next step."#;
137
138    const INTERACTIVE_SUFFIX: &str =
139        "Use `ask` only for genuine ambiguity or irreversible user-facing choices. Batch prompts.";
140    const NONINTERACTIVE_SUFFIX: &str = "Non-interactive mode: stay unblocked without questions. Choose the safest reasonable path, state brief assumptions, and finish the inspect/edit/verify flow.";
141    const ASK_SUFFIX: &str = r#"RESEARCH-ONLY mode. Use only list, read, search, sloc, and webfetch. Stay no-write: leave files unchanged and skip `bash`. Focus on facts only, citing file paths and brief evidence."#;
142    const PLAN_SYSTEM: &str = r#"PLAN mode. Stay read-only. Use only list, read, search, sloc, todo for in-memory planning, ask when interactive, and webfetch when available. Keep files unchanged, skip shell commands, and describe changes as proposed rather than applied."#;
143    const ACCEPT_EDITS_SYSTEM: &str = r#"ACCEPT-EDITS mode. File edits may run without asking. Keep edits small and targeted, inspect before changing, and reach for `bash` only when genuinely necessary."#;
144    const AUTO_APPROVE_SYSTEM: &str = r#"AUTO-APPROVE mode. Tools may run without asking. Still avoid destructive commands, broad rewrites, credential exposure, persistence changes, and network/file/process expansion unless clearly needed. Treat shell and replacement tools as strict side effects: inspect first, then run the smallest command/edit."#;
145    const TODO_SYSTEM: &str = r#"Current in-memory todo:
146{todos}"#;
147
148    pub fn session_text_value(section: &str, key: &str) -> Result<String> {
149        let value = match (section, key) {
150            ("system", "base") => BASE_SYSTEM,
151            ("system", "interactive_suffix") => INTERACTIVE_SUFFIX,
152            ("system", "noninteractive_suffix") => NONINTERACTIVE_SUFFIX,
153            ("system", "ask_suffix") => ASK_SUFFIX,
154            ("transcript", "todo_system") => TODO_SYSTEM,
155            _ => bail!("missing session text key: {section}.{key}"),
156        };
157        Ok(value.to_string())
158    }
159
160    pub fn tool_description(name: &str) -> String {
161        match name {
162        "list" => "List workspace paths. Use first for discovery. `path` is a workspace-relative glob and defaults to `*`. Returns items, count, and truncation state.",
163        "read" => "Read one UTF-8 text file. Prefer narrow `offset`/`limit` slices over full-file reads.",
164        "search" => "Search workspace text with ripgrep-style Rust regex. Use `mode=literal` for exact strings.",
165        "replace" => "Replace workspace text with Rust regex captures, or exact text with `mode=literal`. Inspect/search before changing.",
166        "sloc" => "Count source lines with tokei for repository sizing. `path` may be one path or whitespace-separated paths.",
167        "bash" => "Run a shell command in the workspace. Use only when file tools are insufficient or when you must run/check something.",
168        "ask" => "Ask the user in interactive runs. Reserve for genuine ambiguity or irreversible choices.",
169        "webfetch" => "Fetch public web pages/files. Follows public redirects by default; blocks localhost/private IPs and sensitive headers.",
170        "todo" => "Manage the in-memory todo list. Available in read-only modes; persistence to TODO.md is opt-in and requires write approval.",
171        other => other,
172    }
173    .to_string()
174    }
175
176    pub fn safety_mode(mode: &str) -> Result<SafetyMode> {
177        SafetyMode::parse(mode)
178    }
179
180    pub fn tool_policy(mode: &str) -> ToolPolicy {
181        let mode = SafetyMode::parse(mode).unwrap_or(SafetyMode::Default);
182        mode.policy()
183    }
184
185    pub fn config_root() -> PathBuf {
186        if let Ok(raw) = env::var("OY_CONFIG") {
187            return PathBuf::from(&raw)
188                .expand_home()
189                .unwrap_or_else(|_| PathBuf::from(raw));
190        }
191        config_dir()
192            .unwrap_or_else(|| PathBuf::from(".config"))
193            .join(DEFAULT_CONFIG_DIR_NAME)
194            .join("config.json")
195    }
196
197    pub fn oy_root() -> Result<PathBuf> {
198        let raw_root = env::var("OY_ROOT").unwrap_or_else(|_| ".".to_string());
199        let path = PathBuf::from(&raw_root)
200            .expand_home()
201            .unwrap_or_else(|_| PathBuf::from(raw_root))
202            .canonicalize()
203            .context("failed to resolve workspace root")?;
204        if !path.is_dir() {
205            bail!("Workspace root is not a directory: {}", path.display());
206        }
207        Ok(path)
208    }
209
210    pub fn config_dir_path() -> PathBuf {
211        config_root()
212            .parent()
213            .map(Path::to_path_buf)
214            .unwrap_or_else(|| PathBuf::from(format!(".config/{DEFAULT_CONFIG_DIR_NAME}")))
215    }
216
217    pub fn sessions_dir() -> Result<PathBuf> {
218        let dir = config_dir_path().join("sessions");
219        create_private_dir_all(&dir)?;
220        Ok(dir)
221    }
222
223    pub fn load_model_config() -> Result<SavedModelConfig> {
224        let path = config_root();
225        if !path.exists() {
226            return Ok(SavedModelConfig::default());
227        }
228        let data = fs::read_to_string(&path)
229            .with_context(|| format!("failed reading {}", path.display()))?;
230        let parsed = serde_json::from_str::<SavedModelConfig>(&data)
231            .with_context(|| format!("failed parsing {}", path.display()))?;
232        Ok(parsed)
233    }
234
235    pub fn save_model_config(model_spec: &str) -> Result<()> {
236        let path = config_root();
237        if let Some(parent) = path.parent() {
238            create_private_dir_all(parent)?;
239        }
240        let payload = saved_model_config_from_selection(model_spec);
241        let text = serde_json::to_string_pretty(&payload)?;
242        write_private_file(&path, text.as_bytes())?;
243        Ok(())
244    }
245
246    pub fn saved_model_config_from_selection(model_spec: &str) -> SavedModelConfig {
247        let model_spec = model_spec.trim();
248        let (prefix, model) = split_model_spec(model_spec);
249        if let Some(shim) = prefix.filter(|shim| is_routing_shim(shim)) {
250            return SavedModelConfig {
251                model: Some(genai_model_for_shim(shim, model)),
252                shim: Some(shim.to_string()),
253            };
254        }
255        SavedModelConfig {
256            model: Some(model_spec.to_string()),
257            shim: None,
258        }
259    }
260
261    fn genai_model_for_shim(shim: &str, model: &str) -> String {
262        if is_copilot_shim(shim) && is_openai_responses_model(model) {
263            format!("openai_resp::{model}")
264        } else {
265            model.to_string()
266        }
267    }
268
269    pub fn policy_risk_label(policy: &ToolPolicy) -> &'static str {
270        if policy.read_only {
271            "read-only: no file edits or shell"
272        } else if policy.shell == Approval::Auto {
273            "high: auto shell"
274        } else if policy.files_write == Approval::Auto {
275            "medium: auto edits"
276        } else {
277            "normal: asks before edits/shell"
278        }
279    }
280
281    pub fn is_openai_responses_model(model: &str) -> bool {
282        let (_, model) = split_model_spec(model);
283        let model = model
284            .rsplit_once('/')
285            .map(|(_, name)| name)
286            .unwrap_or(model);
287        model.starts_with("gpt-5.5")
288            || (model.starts_with("gpt") && (model.contains("codex") || model.contains("pro")))
289    }
290
291    pub fn is_routing_shim(shim: &str) -> bool {
292        matches!(
293            shim,
294            "openai" | "copilot" | "bedrock-mantle" | "opencode" | "opencode-go"
295        ) || shim
296            .strip_prefix("local-")
297            .is_some_and(|port| port.parse::<u16>().is_ok())
298    }
299
300    fn is_copilot_shim(shim: &str) -> bool {
301        shim == "copilot"
302    }
303
304    pub fn split_model_spec(spec: &str) -> (Option<&str>, &str) {
305        if let Some(index) = spec.find("::") {
306            let (left, right) = spec.split_at(index);
307            return (Some(left), &right[2..]);
308        }
309        (None, spec)
310    }
311
312    pub fn non_interactive() -> bool {
313        env_flag("OY_NON_INTERACTIVE", false)
314    }
315
316    pub fn can_prompt() -> bool {
317        std::io::stdin().is_terminal() && !non_interactive()
318    }
319
320    pub fn context_config() -> ContextConfig {
321        let limit_tokens = parse_usize_env("OY_CONTEXT_LIMIT", 128_000).max(1_000);
322        let output_reserve_tokens = parse_usize_env("OY_CONTEXT_OUTPUT_RESERVE", 12_000);
323        let safety_reserve_tokens = parse_usize_env("OY_CONTEXT_SAFETY_RESERVE", 4_000);
324        ContextConfig {
325            limit_tokens,
326            output_reserve_tokens,
327            safety_reserve_tokens,
328            trigger_ratio: parse_f64_env("OY_COMPACT_TRIGGER", 0.80).clamp(0.10, 1.0),
329            recent_messages: parse_usize_env("OY_COMPACT_RECENT_MESSAGES", 16).max(1),
330            tool_output_tokens: parse_usize_env("OY_COMPACT_TOOL_OUTPUT_TOKENS", 4_000).max(256),
331            summary_tokens: parse_usize_env("OY_COMPACT_SUMMARY_TOKENS", 8_000).max(512),
332        }
333    }
334
335    pub fn system_prompt(interactive: bool, mode: &str) -> String {
336        let mut prompt = BASE_SYSTEM.to_string();
337        prompt.push('\n');
338        prompt.push_str(if interactive {
339            INTERACTIVE_SUFFIX
340        } else {
341            NONINTERACTIVE_SUFFIX
342        });
343        if let Ok(mode) = safety_mode(mode) {
344            let suffix = mode.system_prompt_suffix().trim();
345            if !suffix.is_empty() {
346                prompt.push_str("\n\n");
347                prompt.push_str(suffix);
348            }
349        }
350        if let Ok(raw) = env::var("OY_SYSTEM_FILE") {
351            let path = PathBuf::from(&raw)
352                .expand_home()
353                .unwrap_or_else(|_| PathBuf::from(raw));
354            if path.is_file()
355                && let Ok(extra) = fs::read_to_string(path)
356                && !extra.trim().is_empty()
357            {
358                prompt.push_str("\n\n");
359                prompt.push_str(extra.trim());
360            }
361        }
362        prompt
363    }
364
365    pub fn ask_system_prompt(prompt: &str) -> String {
366        format!("{}\n\n{}", prompt.trim_end(), ASK_SUFFIX)
367    }
368
369    pub fn max_bash_cmd_bytes() -> usize {
370        env::var("OY_MAX_BASH_CMD_BYTES")
371            .ok()
372            .and_then(|v| v.parse().ok())
373            .unwrap_or(16 * 1024)
374    }
375
376    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
377    pub enum ToolRoundLimit {
378        Limited(usize),
379        Unlimited,
380    }
381
382    impl ToolRoundLimit {
383        pub fn exceeded(self, completed_rounds: usize) -> bool {
384            matches!(self, Self::Limited(max) if completed_rounds > max)
385        }
386
387        pub fn label(self) -> String {
388            match self {
389                Self::Limited(max) => max.to_string(),
390                Self::Unlimited => "unlimited".to_string(),
391            }
392        }
393    }
394
395    pub fn max_tool_rounds(default: usize) -> ToolRoundLimit {
396        parse_tool_round_limit(env::var("OY_MAX_TOOL_ROUNDS").ok().as_deref(), default)
397    }
398
399    pub fn save_session_file(name: Option<&str>, file: &SessionFile) -> Result<PathBuf> {
400        let sessions = sessions_dir()?;
401        let stem = name
402            .filter(|s| !s.trim().is_empty())
403            .map(sanitize_session_name)
404            .unwrap_or_else(|| Utc::now().format("%Y%m%d-%H%M%S").to_string());
405        let path = sessions.join(format!("{stem}.json"));
406        let body = serde_json::to_string_pretty(file)?;
407        write_private_file(&path, body.as_bytes())?;
408        Ok(path)
409    }
410
411    pub fn list_saved_sessions() -> Result<Vec<PathBuf>> {
412        let dir = sessions_dir()?;
413        let mut items = fs::read_dir(&dir)?
414            .filter_map(|entry| entry.ok().map(|e| e.path()))
415            .filter(|path| path.extension().and_then(|e| e.to_str()) == Some("json"))
416            .collect::<Vec<_>>();
417        items.sort_by_key(|path| {
418            fs::metadata(path)
419                .and_then(|m| m.modified())
420                .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
421        });
422        items.reverse();
423        Ok(items)
424    }
425
426    pub fn resolve_saved_session(name: Option<&str>) -> Result<Option<PathBuf>> {
427        let sessions = list_saved_sessions()?;
428        if sessions.is_empty() {
429            return Ok(None);
430        }
431        let Some(name) = name else {
432            return Ok(sessions.first().cloned());
433        };
434        if let Ok(index) = name.parse::<usize>()
435            && index >= 1
436            && index <= sessions.len()
437        {
438            return Ok(Some(sessions[index - 1].clone()));
439        }
440        if let Some(exact) = sessions
441            .iter()
442            .find(|p| p.file_stem().and_then(|s| s.to_str()) == Some(name))
443        {
444            return Ok(Some(exact.clone()));
445        }
446        Ok(sessions
447            .iter()
448            .find(|p| {
449                p.file_stem()
450                    .and_then(|s| s.to_str())
451                    .is_some_and(|s| s.contains(name))
452            })
453            .cloned())
454    }
455
456    pub fn load_session_file(path: &Path) -> Result<SessionFile> {
457        let data = fs::read_to_string(path)
458            .with_context(|| format!("failed reading {}", path.display()))?;
459        serde_json::from_str(&data).with_context(|| format!("failed parsing {}", path.display()))
460    }
461
462    pub fn sanitize_session_name(name: &str) -> String {
463        let mut out = String::with_capacity(name.len());
464        for ch in name.chars() {
465            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
466                out.push(ch);
467            } else if ch.is_whitespace() {
468                out.push('-');
469            }
470        }
471        let trimmed = out.trim_matches('-');
472        if trimmed.is_empty() {
473            "session".to_string()
474        } else {
475            trimmed.to_string()
476        }
477    }
478
479    fn parse_usize_env(name: &str, default: usize) -> usize {
480        env::var(name)
481            .ok()
482            .and_then(|v| v.trim().parse::<usize>().ok())
483            .unwrap_or(default)
484    }
485
486    fn parse_tool_round_limit(value: Option<&str>, default: usize) -> ToolRoundLimit {
487        let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
488            return ToolRoundLimit::Limited(default.max(1));
489        };
490        if matches!(
491            value.to_ascii_lowercase().as_str(),
492            "unlimited" | "none" | "off"
493        ) {
494            return ToolRoundLimit::Unlimited;
495        }
496        match value.parse::<usize>() {
497            Ok(0) => ToolRoundLimit::Unlimited,
498            Ok(max) => ToolRoundLimit::Limited(max),
499            Err(_) => ToolRoundLimit::Limited(default.max(1)),
500        }
501    }
502
503    fn parse_f64_env(name: &str, default: f64) -> f64 {
504        env::var(name)
505            .ok()
506            .and_then(|v| v.trim().parse::<f64>().ok())
507            .filter(|v| v.is_finite())
508            .unwrap_or(default)
509    }
510
511    pub fn write_workspace_file(path: &Path, bytes: &[u8]) -> Result<()> {
512        reject_symlink_destination(path)?;
513        if let Some(parent) = path.parent() {
514            fs::create_dir_all(parent)
515                .with_context(|| format!("failed creating {}", parent.display()))?;
516        }
517        #[cfg(unix)]
518        {
519            use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
520            let mode = fs::metadata(path)
521                .ok()
522                .map(|m| m.permissions().mode() & 0o777)
523                .unwrap_or(0o600);
524            let mut file = fs::OpenOptions::new()
525                .create(true)
526                .write(true)
527                .truncate(true)
528                .mode(mode)
529                .open(path)
530                .with_context(|| format!("failed writing {}", path.display()))?;
531            file.write_all(bytes)
532                .with_context(|| format!("failed writing {}", path.display()))?;
533            let mut perms = file.metadata()?.permissions();
534            perms.set_mode(mode);
535            file.set_permissions(perms)?;
536            Ok(())
537        }
538        #[cfg(not(unix))]
539        {
540            fs::write(path, bytes).with_context(|| format!("failed writing {}", path.display()))
541        }
542    }
543
544    pub fn resolve_workspace_output_path(root: &Path, requested: &Path) -> Result<PathBuf> {
545        if requested.is_absolute()
546            || requested
547                .components()
548                .any(|c| matches!(c, std::path::Component::ParentDir))
549        {
550            bail!(
551                "output path must stay inside workspace: {}",
552                requested.display()
553            );
554        }
555        let root = root
556            .canonicalize()
557            .context("failed to resolve workspace root")?;
558        let path = root.join(requested);
559        let parent = path.parent().unwrap_or(&root);
560        if parent.exists() {
561            let resolved_parent = parent
562                .canonicalize()
563                .with_context(|| format!("failed resolving {}", parent.display()))?;
564            if !resolved_parent.starts_with(&root) {
565                bail!("output path escapes workspace: {}", requested.display());
566            }
567        }
568        reject_symlink_destination(&path)?;
569        Ok(path)
570    }
571
572    pub fn reject_symlink_destination(path: &Path) -> Result<()> {
573        match fs::symlink_metadata(path) {
574            Ok(meta) if meta.file_type().is_symlink() => {
575                bail!("refusing to write symlink: {}", path.display())
576            }
577            Ok(_) => Ok(()),
578            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
579            Err(err) => Err(err).with_context(|| format!("failed checking {}", path.display())),
580        }
581    }
582
583    pub fn write_private_file(path: &Path, bytes: &[u8]) -> Result<()> {
584        #[cfg(unix)]
585        {
586            use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
587            if let Some(parent) = path.parent() {
588                create_private_dir_all(parent)?;
589            }
590            let mut file = fs::OpenOptions::new()
591                .create(true)
592                .write(true)
593                .truncate(true)
594                .mode(0o600)
595                .open(path)
596                .with_context(|| format!("failed writing {}", path.display()))?;
597            file.write_all(bytes)
598                .with_context(|| format!("failed writing {}", path.display()))?;
599            let mut perms = file.metadata()?.permissions();
600            perms.set_mode(0o600);
601            file.set_permissions(perms)?;
602            Ok(())
603        }
604        #[cfg(not(unix))]
605        {
606            fs::write(path, bytes).with_context(|| format!("failed writing {}", path.display()))
607        }
608    }
609
610    pub fn create_private_dir_all(path: &Path) -> Result<()> {
611        fs::create_dir_all(path).with_context(|| format!("failed to create {}", path.display()))?;
612        #[cfg(unix)]
613        {
614            use std::os::unix::fs::PermissionsExt as _;
615            let mut perms = fs::metadata(path)?.permissions();
616            perms.set_mode(0o700);
617            fs::set_permissions(path, perms)?;
618        }
619        Ok(())
620    }
621
622    fn env_flag(name: &str, default: bool) -> bool {
623        match env::var(name) {
624            Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
625                "1" | "true" | "yes" | "on" => true,
626                "0" | "false" | "no" | "off" => false,
627                _ => default,
628            },
629            Err(_) => default,
630        }
631    }
632
633    trait ExpandHome {
634        fn expand_home(self) -> Result<PathBuf>;
635    }
636
637    impl ExpandHome for PathBuf {
638        fn expand_home(self) -> Result<PathBuf> {
639            let text = self.to_string_lossy();
640            if text == "~" || text.starts_with("~/") {
641                let home = dirs::home_dir().context("home directory not found")?;
642                let suffix = text
643                    .strip_prefix('~')
644                    .unwrap_or_default()
645                    .trim_start_matches('/');
646                return Ok(if suffix.is_empty() {
647                    home
648                } else {
649                    home.join(suffix)
650                });
651            }
652            Ok(self)
653        }
654    }
655
656    #[cfg(test)]
657    mod tests {
658        use super::*;
659
660        #[test]
661        fn mode_policy_and_risk_labels_are_centralized() {
662            let plan = tool_policy("plan");
663            assert_eq!(safety_mode("ask").unwrap().name(), "default");
664            assert_eq!(safety_mode("read_only").unwrap().name(), "plan");
665            assert_eq!(safety_mode("edit").unwrap().name(), "accept-edits");
666            assert_eq!(safety_mode("yolo").unwrap().name(), "auto-approve");
667            assert!(plan.read_only);
668            assert_eq!(
669                policy_risk_label(&plan),
670                "read-only: no file edits or shell"
671            );
672            assert_eq!(
673                policy_risk_label(&tool_policy("accept-edits")),
674                "medium: auto edits"
675            );
676            assert_eq!(
677                policy_risk_label(&tool_policy("auto-approve")),
678                "high: auto shell"
679            );
680        }
681
682        #[test]
683        fn output_paths_stay_in_workspace() {
684            let dir = tempfile::tempdir().unwrap();
685            assert!(resolve_workspace_output_path(dir.path(), Path::new("notes/out.md")).is_ok());
686            assert!(resolve_workspace_output_path(dir.path(), Path::new("../out.md")).is_err());
687            assert!(resolve_workspace_output_path(dir.path(), Path::new("/tmp/out.md")).is_err());
688        }
689
690        #[cfg(unix)]
691        #[test]
692        fn output_paths_reject_symlink_destinations() {
693            use std::os::unix::fs::symlink;
694            let dir = tempfile::tempdir().unwrap();
695            let target = dir.path().join("target.md");
696            fs::write(&target, "safe").unwrap();
697            symlink(&target, dir.path().join("link.md")).unwrap();
698            let err = resolve_workspace_output_path(dir.path(), Path::new("link.md")).unwrap_err();
699            assert!(err.to_string().contains("refusing to write symlink"));
700        }
701
702        #[test]
703        fn default_config_dir_name_is_rust_specific() {
704            assert_eq!(DEFAULT_CONFIG_DIR_NAME, "oy-rust");
705        }
706
707        #[test]
708        fn saved_model_config_keeps_exact_genai_model_and_infers_routing_shim() {
709            let saved = saved_model_config_from_selection("copilot::gpt-5.5");
710            assert_eq!(saved.model.as_deref(), Some("openai_resp::gpt-5.5"));
711            assert_eq!(saved.shim.as_deref(), Some("copilot"));
712
713            let saved = saved_model_config_from_selection("openai_resp::gpt-5.5");
714            assert_eq!(saved.model.as_deref(), Some("openai_resp::gpt-5.5"));
715            assert_eq!(saved.shim.as_deref(), None);
716        }
717
718        #[test]
719        fn split_model_spec_supports_double_colon() {
720            assert_eq!(
721                split_model_spec("copilot::gpt-4.1-mini"),
722                (Some("copilot"), "gpt-4.1-mini")
723            );
724        }
725
726        #[test]
727        fn split_model_spec_leaves_plain_models_untouched() {
728            assert_eq!(split_model_spec("gpt-5.4-mini"), (None, "gpt-5.4-mini"));
729        }
730
731        #[test]
732        fn session_text_loads_base_prompt() {
733            assert!(
734                session_text_value("system", "base")
735                    .unwrap()
736                    .contains("You are oy")
737            );
738        }
739
740        #[test]
741        fn session_file_ignores_legacy_mode_and_defaults_missing_fields() {
742            let raw = r#"{
743            "model": "gpt-test",
744            "agent": "default",
745            "mode": "auto-approve",
746            "saved_at": "2026-01-01T00:00:00",
747            "transcript": {"messages": []}
748        }"#;
749            let file: SessionFile = serde_json::from_str(raw).unwrap();
750            assert_eq!(file.model, "gpt-test");
751            assert!(file.todos.is_empty());
752            assert!(file.workspace_root.is_none());
753        }
754
755        #[test]
756        fn session_file_save_omits_mode() {
757            let file = SessionFile {
758                model: "gpt-test".into(),
759                saved_at: "2026-01-01T00:00:00".into(),
760                workspace_root: None,
761                transcript: serde_json::json!({"messages": []}),
762                todos: Vec::new(),
763            };
764            let raw = serde_json::to_value(&file).unwrap();
765            assert!(raw.get("mode").is_none());
766            assert!(raw.get("agent").is_none());
767        }
768
769        #[test]
770        fn tool_round_limit_supports_high_and_unlimited_values() {
771            assert_eq!(
772                parse_tool_round_limit(None, 512),
773                ToolRoundLimit::Limited(512)
774            );
775            assert_eq!(
776                parse_tool_round_limit(Some("2048"), 512),
777                ToolRoundLimit::Limited(2048)
778            );
779            assert_eq!(
780                parse_tool_round_limit(Some("0"), 512),
781                ToolRoundLimit::Unlimited
782            );
783            assert_eq!(
784                parse_tool_round_limit(Some("unlimited"), 512),
785                ToolRoundLimit::Unlimited
786            );
787            assert_eq!(
788                parse_tool_round_limit(Some("bad"), 512),
789                ToolRoundLimit::Limited(512)
790            );
791            assert!(ToolRoundLimit::Limited(2).exceeded(3));
792            assert!(!ToolRoundLimit::Unlimited.exceeded(usize::MAX));
793        }
794    }
795}
796
797// === ui ===
798pub(crate) mod ui {
799    use std::borrow::Cow;
800    use std::fmt::{Display, Write as _};
801    use std::io::IsTerminal as _;
802    use std::sync::LazyLock;
803    use std::sync::atomic::{AtomicU8, Ordering};
804    use std::time::Duration;
805    use syntect::easy::HighlightLines;
806    use syntect::highlighting::{Theme, ThemeSet};
807    use syntect::parsing::SyntaxSet;
808    use syntect::util::as_24_bit_terminal_escaped;
809    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
810
811    /// Controls how much user-facing output `oy` writes while it runs.
812    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
813    pub enum OutputMode {
814        /// Suppress normal progress output.
815        Quiet = 0,
816        /// Show standard human-readable progress output.
817        Normal = 1,
818        /// Show fuller tool previews and diagnostic context.
819        Verbose = 2,
820        /// Prefer machine-readable JSON where a command supports it.
821        Json = 3,
822    }
823
824    static OUTPUT_MODE: AtomicU8 = AtomicU8::new(OutputMode::Normal as u8);
825
826    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
827    enum ColorMode {
828        Auto,
829        Always,
830        Never,
831    }
832
833    static COLOR_MODE: LazyLock<ColorMode> = LazyLock::new(color_mode_from_env);
834
835    pub fn init_output_mode(mode: Option<OutputMode>) {
836        let mode = mode
837            .or_else(output_mode_from_env)
838            .unwrap_or(OutputMode::Normal);
839        set_output_mode(mode);
840    }
841
842    /// Sets the process-wide output mode used by CLI rendering helpers.
843    pub fn set_output_mode(mode: OutputMode) {
844        OUTPUT_MODE.store(mode as u8, Ordering::Relaxed);
845    }
846
847    pub fn output_mode() -> OutputMode {
848        match OUTPUT_MODE.load(Ordering::Relaxed) {
849            0 => OutputMode::Quiet,
850            2 => OutputMode::Verbose,
851            3 => OutputMode::Json,
852            _ => OutputMode::Normal,
853        }
854    }
855
856    pub fn is_quiet() -> bool {
857        matches!(output_mode(), OutputMode::Quiet | OutputMode::Json)
858    }
859
860    pub fn is_json() -> bool {
861        matches!(output_mode(), OutputMode::Json)
862    }
863
864    pub fn is_verbose() -> bool {
865        matches!(output_mode(), OutputMode::Verbose)
866    }
867
868    fn output_mode_from_env() -> Option<OutputMode> {
869        if truthy_env("OY_QUIET") {
870            return Some(OutputMode::Quiet);
871        }
872        if truthy_env("OY_VERBOSE") {
873            return Some(OutputMode::Verbose);
874        }
875        match std::env::var("OY_OUTPUT")
876            .ok()?
877            .to_ascii_lowercase()
878            .as_str()
879        {
880            "quiet" => Some(OutputMode::Quiet),
881            "verbose" => Some(OutputMode::Verbose),
882            "json" => Some(OutputMode::Json),
883            "normal" => Some(OutputMode::Normal),
884            _ => None,
885        }
886    }
887
888    fn truthy_env(name: &str) -> bool {
889        matches!(
890            std::env::var(name).ok().as_deref(),
891            Some("1" | "true" | "yes" | "on")
892        )
893    }
894
895    fn color_mode_from_env() -> ColorMode {
896        color_mode_from_values(
897            std::env::var_os("NO_COLOR").is_some(),
898            std::env::var("OY_COLOR").ok().as_deref(),
899        )
900    }
901
902    fn color_mode_from_values(no_color: bool, oy_color: Option<&str>) -> ColorMode {
903        if no_color {
904            return ColorMode::Never;
905        }
906        match oy_color.map(str::to_ascii_lowercase).as_deref() {
907            Some("always" | "1" | "true" | "yes" | "on") => ColorMode::Always,
908            Some("never" | "0" | "false" | "no" | "off") => ColorMode::Never,
909            _ => ColorMode::Auto,
910        }
911    }
912
913    pub fn color_enabled() -> bool {
914        color_enabled_for_stdout(std::io::stdout().is_terminal())
915    }
916
917    fn color_enabled_for_stdout(stdout_is_terminal: bool) -> bool {
918        color_enabled_for_mode(*COLOR_MODE, stdout_is_terminal)
919    }
920
921    fn color_enabled_for_mode(mode: ColorMode, stdout_is_terminal: bool) -> bool {
922        match mode {
923            ColorMode::Always => true,
924            ColorMode::Never => false,
925            ColorMode::Auto => stdout_is_terminal,
926        }
927    }
928
929    pub fn terminal_width() -> usize {
930        terminal_size::terminal_size()
931            .map(|(terminal_size::Width(width), _)| width as usize)
932            .filter(|width| *width >= 40)
933            .unwrap_or(100)
934    }
935
936    pub fn paint(code: &str, text: impl Display) -> String {
937        if color_enabled() {
938            format!("\x1b[{code}m{text}\x1b[0m")
939        } else {
940            text.to_string()
941        }
942    }
943
944    pub fn faint(text: impl Display) -> String {
945        paint("2", text)
946    }
947
948    pub fn bold(text: impl Display) -> String {
949        paint("1", text)
950    }
951
952    pub fn cyan(text: impl Display) -> String {
953        paint("36", text)
954    }
955
956    pub fn green(text: impl Display) -> String {
957        paint("32", text)
958    }
959
960    pub fn yellow(text: impl Display) -> String {
961        paint("33", text)
962    }
963
964    pub fn red(text: impl Display) -> String {
965        paint("31", text)
966    }
967
968    pub fn magenta(text: impl Display) -> String {
969        paint("35", text)
970    }
971
972    pub fn status_text(ok: bool, text: impl Display) -> String {
973        if ok { green(text) } else { red(text) }
974    }
975
976    pub fn bool_text(value: bool) -> String {
977        status_text(value, value)
978    }
979
980    pub fn path(text: impl Display) -> String {
981        paint("1;36", text)
982    }
983
984    pub fn out(text: &str) {
985        print!("{text}");
986    }
987
988    pub fn err(text: &str) {
989        eprint!("{text}");
990    }
991
992    pub fn line(text: impl Display) {
993        out(&format!("{text}\n"));
994    }
995
996    pub fn err_line(text: impl Display) {
997        err(&format!("{text}\n"));
998    }
999
1000    pub fn markdown(text: &str) {
1001        out(&render_markdown(text));
1002    }
1003
1004    fn render_markdown(text: &str) -> String {
1005        if !color_enabled() {
1006            return text.to_string();
1007        }
1008        let mut in_fence = false;
1009        let mut out = String::new();
1010        for line in text.lines() {
1011            let trimmed = line.trim_start();
1012            let rendered = if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
1013                in_fence = !in_fence;
1014                faint(line)
1015            } else if in_fence {
1016                cyan(line)
1017            } else if trimmed.starts_with('#') {
1018                paint("1;35", line)
1019            } else if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
1020                cyan(line)
1021            } else {
1022                line.to_string()
1023            };
1024            let _ = writeln!(out, "{rendered}");
1025        }
1026        if text.ends_with('\n') {
1027            out
1028        } else {
1029            out.trim_end_matches('\n').to_string()
1030        }
1031    }
1032
1033    pub fn code(path: &str, text: &str, first_line: usize) -> String {
1034        numbered_block(path, &normalize_code_preview_text(text), first_line)
1035    }
1036
1037    pub fn text_block(title: &str, text: &str) -> String {
1038        numbered_block(title, text, 1)
1039    }
1040
1041    pub fn block_title(title: &str) -> String {
1042        path(format_args!("── {title}"))
1043    }
1044
1045    #[cfg(test)]
1046    fn numbered_line(line_number: usize, width: usize, text: &str) -> String {
1047        numbered_line_with_max_width(line_number, width, text, usize::MAX)
1048    }
1049
1050    fn numbered_line_with_max_width(
1051        line_number: usize,
1052        width: usize,
1053        text: &str,
1054        max_width: usize,
1055    ) -> String {
1056        let text = normalize_code_preview_text(text);
1057        let prefix = format!(
1058            "{} {} ",
1059            faint(format_args!("{line_number:>width$}")),
1060            faint("│")
1061        );
1062        let available = max_width
1063            .saturating_sub(ansi_stripped_width(&prefix))
1064            .max(1);
1065        format!("{prefix}{}", truncate_width(&text, available))
1066    }
1067
1068    fn normalize_code_preview_text(text: &str) -> Cow<'_, str> {
1069        const TAB_WIDTH: usize = 4;
1070        if !text.contains('\t') {
1071            return Cow::Borrowed(text);
1072        }
1073
1074        let mut out = String::with_capacity(text.len());
1075        let mut column = 0usize;
1076        for ch in text.chars() {
1077            match ch {
1078                '\t' => {
1079                    let spaces = TAB_WIDTH - (column % TAB_WIDTH);
1080                    out.extend(std::iter::repeat_n(' ', spaces));
1081                    column += spaces;
1082                }
1083                '\n' | '\r' => {
1084                    out.push(ch);
1085                    column = 0;
1086                }
1087                _ => {
1088                    out.push(ch);
1089                    column += UnicodeWidthChar::width(ch).unwrap_or(0);
1090                }
1091            }
1092        }
1093        Cow::Owned(out)
1094    }
1095
1096    fn numbered_block(title: &str, text: &str, first_line: usize) -> String {
1097        let title = if title.is_empty() { "text" } else { title };
1098        let line_count = text.lines().count().max(1);
1099        let width = first_line
1100            .saturating_add(line_count.saturating_sub(1))
1101            .max(1)
1102            .to_string()
1103            .len();
1104        let max_width = terminal_width().saturating_sub(4).max(40);
1105        let code_width = max_width.saturating_sub(width + 3).max(1);
1106        let mut out = String::new();
1107        let _ = writeln!(out, "{}", truncate_width(&block_title(title), max_width));
1108        if text.is_empty() {
1109            let _ = writeln!(
1110                out,
1111                "{}",
1112                numbered_line_with_max_width(first_line, width, "", max_width)
1113            );
1114        } else {
1115            let display_text = text
1116                .lines()
1117                .map(|line| truncate_width(line, code_width))
1118                .collect::<Vec<_>>()
1119                .join("\n");
1120            let highlighted = highlighted_block(title, &display_text);
1121            let lines = highlighted.as_deref().unwrap_or(&display_text).lines();
1122            for (idx, line) in lines.enumerate() {
1123                let _ = writeln!(
1124                    out,
1125                    "{}",
1126                    numbered_line_with_max_width(first_line + idx, width, line, max_width)
1127                );
1128            }
1129        }
1130        out.trim_end().to_string()
1131    }
1132
1133    static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
1134    static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
1135
1136    fn highlighted_block(title: &str, text: &str) -> Option<String> {
1137        if !color_enabled() {
1138            return None;
1139        }
1140        let syntax = syntax_for_title(title)?;
1141        let theme = terminal_theme()?;
1142        let mut highlighter = HighlightLines::new(syntax, theme);
1143        let mut out = String::new();
1144        for line in text.lines() {
1145            let ranges = highlighter.highlight_line(line, &SYNTAX_SET).ok()?;
1146            let _ = writeln!(out, "{}", as_24_bit_terminal_escaped(&ranges, false));
1147        }
1148        Some(if text.ends_with('\n') {
1149            out
1150        } else {
1151            out.trim_end_matches('\n').to_string()
1152        })
1153    }
1154
1155    fn syntax_for_title(title: &str) -> Option<&'static syntect::parsing::SyntaxReference> {
1156        let syntaxes = &*SYNTAX_SET;
1157        let name = title.rsplit('/').next().unwrap_or(title);
1158        if let Some(ext) = name.rsplit_once('.').map(|(_, ext)| ext) {
1159            syntaxes.find_syntax_by_extension(ext)
1160        } else {
1161            syntaxes.find_syntax_by_token(name)
1162        }
1163        .or_else(|| syntaxes.find_syntax_by_name(title))
1164    }
1165
1166    fn terminal_theme() -> Option<&'static Theme> {
1167        THEME_SET
1168            .themes
1169            .get("base16-ocean.dark")
1170            .or_else(|| THEME_SET.themes.values().next())
1171    }
1172
1173    pub fn diff(text: &str) -> String {
1174        if !color_enabled() {
1175            return text.to_string();
1176        }
1177        let mut out = String::new();
1178        for line in text.lines() {
1179            let rendered = if line.starts_with("+++") || line.starts_with("---") {
1180                bold(line)
1181            } else if line.starts_with("@@") {
1182                cyan(line)
1183            } else if line.starts_with('+') {
1184                green(line)
1185            } else if line.starts_with('-') {
1186                red(line)
1187            } else {
1188                line.to_string()
1189            };
1190            let _ = writeln!(out, "{rendered}");
1191        }
1192        if text.ends_with('\n') {
1193            out
1194        } else {
1195            out.trim_end_matches('\n').to_string()
1196        }
1197    }
1198
1199    pub fn section(title: &str) {
1200        line(bold(title));
1201    }
1202
1203    pub fn kv(key: &str, value: impl Display) {
1204        line(format_args!(
1205            "  {} {value}",
1206            faint(format_args!("{key:<11}"))
1207        ));
1208    }
1209
1210    pub fn success(text: impl Display) {
1211        line(format_args!("{} {text}", green("✓")));
1212    }
1213
1214    pub fn warn(text: impl Display) {
1215        line(format_args!("{} {text}", yellow("!")));
1216    }
1217
1218    pub fn progress(
1219        label: &str,
1220        current: usize,
1221        total: usize,
1222        detail: impl Display,
1223        elapsed: Duration,
1224    ) {
1225        if is_quiet() {
1226            return;
1227        }
1228        line(progress_line(
1229            label,
1230            current,
1231            total,
1232            &detail.to_string(),
1233            elapsed,
1234        ));
1235    }
1236
1237    fn progress_line(
1238        label: &str,
1239        current: usize,
1240        total: usize,
1241        detail: &str,
1242        elapsed: Duration,
1243    ) -> String {
1244        let total = total.max(1);
1245        let current = current.min(total);
1246        let head = format!(
1247            "  {} {current}/{total} {}",
1248            progress_bar(current, total, 18),
1249            cyan(label)
1250        );
1251        if detail.trim().is_empty() {
1252            format!("{head} · {}", faint(format_duration(elapsed)))
1253        } else {
1254            format!("{head} · {detail} · {}", faint(format_duration(elapsed)))
1255        }
1256    }
1257
1258    fn progress_bar(current: usize, total: usize, width: usize) -> String {
1259        let width = width.max(1);
1260        let total = total.max(1);
1261        let current = current.min(total);
1262        let filled = current.saturating_mul(width) / total;
1263        format!(
1264            "[{}{}]",
1265            green("█".repeat(filled)),
1266            faint("░".repeat(width.saturating_sub(filled)))
1267        )
1268    }
1269
1270    pub fn tool_batch(round: usize, count: usize) {
1271        if is_quiet() {
1272            return;
1273        }
1274        err_line(tool_batch_line(round, count));
1275    }
1276
1277    pub fn tool_start(name: &str, detail: &str) {
1278        if is_quiet() {
1279            return;
1280        }
1281        err_line(tool_start_line(name, detail));
1282    }
1283
1284    pub fn tool_result(name: &str, elapsed: Duration, preview: &str) {
1285        if is_quiet() {
1286            return;
1287        }
1288        let preview = preview.trim_end();
1289        let head = tool_result_head(name, elapsed);
1290        let Some((first, rest)) = preview.split_once('\n') else {
1291            if preview.is_empty() {
1292                err_line(head);
1293            } else {
1294                err_line(format_args!("{head} · {first}", first = preview));
1295            }
1296            return;
1297        };
1298        err_line(format_args!("{head} · {first}"));
1299        for line in rest.lines() {
1300            err_line(format_args!("    {line}"));
1301        }
1302    }
1303
1304    pub fn tool_error(name: &str, elapsed: Duration, err: impl Display) {
1305        if is_quiet() {
1306            return;
1307        }
1308        err_line(format_args!(
1309            "  {} {name} {} · {err:#}",
1310            red("✗"),
1311            format_duration(elapsed)
1312        ));
1313    }
1314
1315    pub fn format_duration(elapsed: Duration) -> String {
1316        if elapsed.as_millis() < 1000 {
1317            format!("{}ms", elapsed.as_millis())
1318        } else {
1319            format!("{:.1}s", elapsed.as_secs_f64())
1320        }
1321    }
1322
1323    fn tool_batch_line(round: usize, count: usize) -> String {
1324        format!("{} tools r{round} ×{count}", magenta("↻"))
1325    }
1326
1327    fn tool_start_line(name: &str, detail: &str) -> String {
1328        if detail.is_empty() {
1329            format!("  {} {name}", cyan("→"))
1330        } else {
1331            format!("  {} {name} · {detail}", cyan("→"))
1332        }
1333    }
1334
1335    fn tool_result_head(name: &str, elapsed: Duration) -> String {
1336        format!("  {} {name} {}", green("✓"), format_duration(elapsed))
1337    }
1338
1339    pub fn compact_spaces(value: &str) -> String {
1340        value.split_whitespace().collect::<Vec<_>>().join(" ")
1341    }
1342
1343    pub fn truncate_chars(text: &str, max: usize) -> String {
1344        truncate_width(text, max)
1345    }
1346
1347    pub fn truncate_width(text: &str, max_width: usize) -> String {
1348        if ansi_stripped_width(text) <= max_width {
1349            return text.to_string();
1350        }
1351        truncate_plain_width(text, max_width)
1352    }
1353
1354    fn truncate_plain_width(text: &str, max_width: usize) -> String {
1355        if UnicodeWidthStr::width(text) <= max_width {
1356            return text.to_string();
1357        }
1358        let ellipsis = "…";
1359        let limit = max_width.saturating_sub(UnicodeWidthStr::width(ellipsis));
1360        let mut out = String::new();
1361        let mut width = 0usize;
1362        for ch in text.chars() {
1363            let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
1364            if width + ch_width > limit {
1365                break;
1366            }
1367            width += ch_width;
1368            out.push(ch);
1369        }
1370        out.push_str(ellipsis);
1371        out
1372    }
1373
1374    fn ansi_stripped_width(text: &str) -> usize {
1375        let mut width = 0usize;
1376        let mut chars = text.chars().peekable();
1377        while let Some(ch) = chars.next() {
1378            if ch == '\u{1b}' && chars.peek() == Some(&'[') {
1379                chars.next();
1380                for next in chars.by_ref() {
1381                    if ('@'..='~').contains(&next) {
1382                        break;
1383                    }
1384                }
1385            } else {
1386                width += UnicodeWidthChar::width(ch).unwrap_or(0);
1387            }
1388        }
1389        width
1390    }
1391
1392    pub fn compact_preview(text: &str, max: usize) -> String {
1393        truncate_width(&compact_spaces(text), max)
1394    }
1395
1396    pub fn clamp_lines(text: &str, max_lines: usize, max_cols: usize) -> String {
1397        let mut out = String::new();
1398        let lines = text.lines().collect::<Vec<_>>();
1399        for line in lines.iter().take(max_lines) {
1400            if !out.is_empty() {
1401                out.push('\n');
1402            }
1403            out.push_str(&truncate_width(line, max_cols));
1404        }
1405        if lines.len() > max_lines {
1406            let _ = write!(out, "\n… {} more lines", lines.len() - max_lines);
1407        }
1408        out
1409    }
1410
1411    #[allow(dead_code)]
1412    pub fn wrap_line(text: &str, indent: &str) -> String {
1413        let width = terminal_width().saturating_sub(indent.width()).max(20);
1414        textwrap::wrap(text, width)
1415            .into_iter()
1416            .map(|line| format!("{indent}{line}"))
1417            .collect::<Vec<_>>()
1418            .join("\n")
1419    }
1420
1421    pub fn head_tail(text: &str, max_chars: usize) -> (String, bool) {
1422        if text.chars().count() <= max_chars {
1423            return (text.to_string(), false);
1424        }
1425        let head_len = max_chars / 2;
1426        let tail_len = max_chars.saturating_sub(head_len);
1427        let head = text.chars().take(head_len).collect::<String>();
1428        let tail = text
1429            .chars()
1430            .rev()
1431            .take(tail_len)
1432            .collect::<Vec<_>>()
1433            .into_iter()
1434            .rev()
1435            .collect::<String>();
1436        let hidden = text
1437            .chars()
1438            .count()
1439            .saturating_sub(head.chars().count() + tail.chars().count());
1440        (
1441            format!("{head}\n… [truncated {hidden} chars] …\n{tail}"),
1442            true,
1443        )
1444    }
1445
1446    #[cfg(test)]
1447    mod tests {
1448        use super::*;
1449
1450        fn color_mode_name(mode: ColorMode) -> &'static str {
1451            match mode {
1452                ColorMode::Auto => "auto",
1453                ColorMode::Always => "always",
1454                ColorMode::Never => "never",
1455            }
1456        }
1457
1458        #[test]
1459        fn color_mode_env_parsing() {
1460            assert_eq!(color_mode_name(color_mode_from_values(false, None)), "auto");
1461            assert_eq!(
1462                color_mode_name(color_mode_from_values(false, Some("always"))),
1463                "always"
1464            );
1465            assert_eq!(
1466                color_mode_name(color_mode_from_values(false, Some("on"))),
1467                "always"
1468            );
1469            assert_eq!(
1470                color_mode_name(color_mode_from_values(false, Some("off"))),
1471                "never"
1472            );
1473            assert_eq!(
1474                color_mode_name(color_mode_from_values(true, Some("always"))),
1475                "never"
1476            );
1477        }
1478
1479        #[test]
1480        fn color_auto_requires_terminal() {
1481            assert!(!color_enabled_for_mode(ColorMode::Auto, false));
1482            assert!(color_enabled_for_mode(ColorMode::Auto, true));
1483            assert!(color_enabled_for_mode(ColorMode::Always, false));
1484            assert!(!color_enabled_for_mode(ColorMode::Never, true));
1485        }
1486
1487        #[test]
1488        fn elapsed_format_is_compact() {
1489            assert_eq!(format_duration(Duration::from_millis(42)), "42ms");
1490            assert_eq!(format_duration(Duration::from_millis(1250)), "1.2s");
1491        }
1492
1493        #[test]
1494        fn progress_line_shows_bar_count_detail_and_elapsed() {
1495            set_output_mode(OutputMode::Normal);
1496            assert_eq!(progress_bar(2, 4, 8), "[████░░░░]");
1497            assert_eq!(
1498                progress_line("review", 2, 4, "chunk 3", Duration::from_millis(1250)),
1499                "  [█████████░░░░░░░░░] 2/4 review · chunk 3 · 1.2s"
1500            );
1501        }
1502
1503        #[test]
1504        fn tool_progress_lines_are_dense() {
1505            set_output_mode(OutputMode::Normal);
1506            assert_eq!(tool_batch_line(2, 3), "↻ tools r2 ×3");
1507            assert_eq!(
1508                tool_start_line("read", "path=src/main.rs"),
1509                "  → read · path=src/main.rs"
1510            );
1511            assert_eq!(
1512                tool_result_head("read", Duration::from_millis(42)),
1513                "  ✓ read 42ms"
1514            );
1515        }
1516
1517        #[test]
1518        fn numbered_line_expands_tabs_to_stable_columns() {
1519            set_output_mode(OutputMode::Normal);
1520            assert_eq!(numbered_line(7, 1, "\tlet x = 1;"), "7 │     let x = 1;");
1521            assert_eq!(numbered_line(8, 1, "ab\tcd"), "8 │ ab  cd");
1522            assert_eq!(
1523                code("demo.rs", "\tfn main() {}\n\t\tprintln!(\"hi\");", 1),
1524                "── demo.rs\n1 │     fn main() {}\n2 │         println!(\"hi\");"
1525            );
1526        }
1527
1528        #[test]
1529        fn numbered_line_clamps_long_read_lines_to_preview_width() {
1530            set_output_mode(OutputMode::Normal);
1531            let line = numbered_line_with_max_width(
1532                394,
1533                3,
1534                r#"        .filter(|line| !line.starts_with(&format!("> {}", prompts::AUDIT_TRANSPARENCY_PREFIX)))"#,
1535                40,
1536            );
1537            assert!(UnicodeWidthStr::width(line.as_str()) <= 40, "{line}");
1538            assert!(line.starts_with("394 │ "));
1539            assert!(line.ends_with('…'));
1540            assert!(!line.contains('\n'));
1541        }
1542
1543        #[test]
1544        fn code_preview_lines_fit_tool_result_indent_width() {
1545            set_output_mode(OutputMode::Normal);
1546            let preview = code(
1547                "src/audit.rs",
1548                r#"pub(crate) fn with_transparency_line(report: &str, snippet: &str) -> String {
1549        .filter(|line| !line.starts_with(&format!("> {}", prompts::AUDIT_TRANSPARENCY_PREFIX)))"#,
1550                390,
1551            );
1552            let max_width = terminal_width().saturating_sub(4).max(40);
1553            for line in preview.lines() {
1554                assert!(
1555                    UnicodeWidthStr::width(line) <= max_width,
1556                    "line exceeded {max_width}: {line}"
1557                );
1558            }
1559        }
1560    }
1561}
1562
1563// === chat ===
1564pub(crate) mod chat {
1565    use anyhow::Result;
1566    use dialoguer::{Input, Select, theme::ColorfulTheme};
1567    use std::fmt::Display;
1568
1569    use reedline_repl_rs::reedline::{
1570        DefaultPrompt, DefaultPromptSegment, EditCommand, Emacs, FileBackedHistory, KeyCode,
1571        KeyModifiers, Reedline, ReedlineEvent, Signal, default_emacs_keybindings,
1572    };
1573    use std::path::PathBuf;
1574
1575    use crate::config;
1576    use crate::model;
1577    use crate::session::{self, Session};
1578
1579    const HISTORY_SIZE: usize = 10_000;
1580
1581    fn chat_line_editor(history_path: PathBuf) -> Result<Reedline> {
1582        let mut keybindings = default_emacs_keybindings();
1583        keybindings.add_binding(KeyModifiers::NONE, KeyCode::Enter, ReedlineEvent::Submit);
1584        let insert_newline = ReedlineEvent::Edit(vec![EditCommand::InsertNewline]);
1585        keybindings.add_binding(KeyModifiers::SHIFT, KeyCode::Enter, insert_newline.clone());
1586        keybindings.add_binding(KeyModifiers::ALT, KeyCode::Enter, insert_newline);
1587
1588        Ok(Reedline::create()
1589            .with_history(Box::new(FileBackedHistory::with_file(
1590                HISTORY_SIZE,
1591                history_path,
1592            )?))
1593            .with_edit_mode(Box::new(Emacs::new(keybindings)))
1594            .use_bracketed_paste(true))
1595    }
1596
1597    pub async fn run_chat(session: &mut Session) -> Result<i32> {
1598        crate::ui::section("oy chat");
1599        crate::ui::kv("keys", "Enter sends · Alt/Shift+Enter newline · /? help");
1600        let history_path = history_path("chat")?;
1601        let mut line_editor = chat_line_editor(history_path.clone())?;
1602        let prompt = DefaultPrompt::new(
1603            DefaultPromptSegment::Basic("oy".to_string()),
1604            DefaultPromptSegment::Empty,
1605        );
1606
1607        loop {
1608            let signal = match line_editor.read_line(&prompt) {
1609                Ok(signal) => signal,
1610                Err(err) if is_cursor_position_timeout(&err) => {
1611                    crate::ui::warn("terminal cursor position timed out; resetting prompt");
1612                    line_editor = chat_line_editor(history_path.clone())?;
1613                    continue;
1614                }
1615                Err(err) => return Err(err.into()),
1616            };
1617
1618            match signal {
1619                Signal::Success(line) => {
1620                    line_editor.sync_history()?;
1621                    if !handle_chat_line(session, line.trim()).await? {
1622                        break;
1623                    }
1624                }
1625                Signal::CtrlD => break,
1626                Signal::CtrlC => {
1627                    line_editor.sync_history()?;
1628                    break;
1629                }
1630            }
1631        }
1632        prompt_update_todo_on_quit(session);
1633        Ok(0)
1634    }
1635
1636    fn is_cursor_position_timeout(err: &impl Display) -> bool {
1637        let text = err.to_string();
1638        text.contains("cursor position") && text.contains("could not be read")
1639    }
1640
1641    fn prompt_update_todo_on_quit(session: &Session) {
1642        if crate::config::can_prompt() && !session.todos.is_empty() {
1643            let active = session
1644                .todos
1645                .iter()
1646                .filter(|item| item.status != "done")
1647                .count();
1648            crate::ui::line(format_args!(
1649                "todo summary: {active}/{} active in memory; use the todo tool with persist=true to write TODO.md",
1650                session.todos.len()
1651            ));
1652        }
1653    }
1654
1655    async fn handle_chat_line(session: &mut Session, line: &str) -> Result<bool> {
1656        if line.is_empty() {
1657            return Ok(true);
1658        }
1659        if let Some(command) = line.strip_prefix('/') {
1660            return handle_slash_command(session, command.trim()).await;
1661        }
1662        run_prompt_with_model_reselect(session, line).await?;
1663        Ok(true)
1664    }
1665
1666    async fn handle_slash_command(session: &mut Session, command: &str) -> Result<bool> {
1667        let mut parts = command.split_whitespace();
1668        let raw_name = parts.next().unwrap_or_default();
1669        let name = normalize_chat_command(raw_name);
1670        match name {
1671            "" => Ok(true),
1672            "help" => {
1673                crate::ui::markdown(&format!("{}\n", chat_help_text()));
1674                Ok(true)
1675            }
1676            "tokens" => tokens_command(session),
1677            "compact" => compact_command(parts.next(), session).await,
1678            "model" => model_command(parts.next(), session).await,
1679            "thinking" => thinking_command(parts.next()),
1680            "debug" | "status" => status_command(session),
1681            "ask" => {
1682                let prompt = parts.collect::<Vec<_>>().join(" ");
1683                ask_command(session, &prompt).await
1684            }
1685            "save" => save_command(parts.next(), session),
1686            "load" => load_command(parts.next(), session),
1687            "undo" => undo_command(session),
1688            "clear" => clear_command(session),
1689            "quit" | "exit" => Ok(false),
1690            other => {
1691                crate::ui::warn(format_args!("unknown command /{other}"));
1692                Ok(true)
1693            }
1694        }
1695    }
1696
1697    fn normalize_chat_command(command: &str) -> &str {
1698        match command {
1699            "h" | "?" => "help",
1700            "t" => "tokens",
1701            "k" => "compact",
1702            "m" => "model",
1703            "d" => "debug",
1704            "s" => "status",
1705            "u" => "undo",
1706            "c" => "clear",
1707            "q" => "quit",
1708            other => other,
1709        }
1710    }
1711
1712    pub(crate) fn chat_help_text() -> String {
1713        [
1714            "Enter sends; Alt/Shift+Enter inserts newline",
1715            "/help (/h, /?) -- show help",
1716            "/status (/s), /debug (/d) -- show model, mode, context, and todos",
1717            "/model [value] (/m) -- show or switch model",
1718            "/ask <question> -- research-only query",
1719            "/save [name], /load [name] -- save or load a session",
1720            "/undo (/u), /clear (/c) -- repair conversation state",
1721            "/quit (/q), /exit -- end session",
1722            "Advanced: /tokens, /compact [llm|deterministic], /thinking [auto|off|low|medium|high]",
1723        ]
1724        .join("\n")
1725    }
1726
1727    async fn ask_command(session: &mut Session, prompt: &str) -> Result<bool> {
1728        if prompt.is_empty() {
1729            anyhow::bail!("Usage: /ask <question>");
1730        }
1731        let answer =
1732            session::run_prompt_read_only(session, &config::ask_system_prompt(prompt)).await?;
1733        if !answer.is_empty() {
1734            crate::ui::markdown(&format!("{answer}\n"));
1735        }
1736        Ok(true)
1737    }
1738
1739    fn tokens_command(session: &Session) -> Result<bool> {
1740        let status = session.context_status();
1741        crate::ui::section("Context");
1742        crate::ui::kv("messages", status.estimate.messages);
1743        crate::ui::kv(
1744            "system",
1745            format_args!("~{} tokens", status.estimate.system_tokens),
1746        );
1747        crate::ui::kv(
1748            "messages",
1749            format_args!("~{} tokens", status.estimate.message_tokens),
1750        );
1751        crate::ui::kv(
1752            "total",
1753            format_args!("~{} tokens", status.estimate.total_tokens),
1754        );
1755        crate::ui::kv("limit", format_args!("{} tokens", status.limit_tokens));
1756        crate::ui::kv(
1757            "input budget",
1758            format_args!("{} tokens", status.input_budget_tokens),
1759        );
1760        crate::ui::kv("trigger", format_args!("{} tokens", status.trigger_tokens));
1761        crate::ui::kv("summary", crate::ui::bool_text(status.summary_present));
1762        Ok(true)
1763    }
1764
1765    async fn compact_command(mode: Option<&str>, session: &mut Session) -> Result<bool> {
1766        let before = session.context_status().estimate.total_tokens;
1767        let stats = match mode.unwrap_or("llm") {
1768            "" | "llm" | "smart" => session.compact_llm().await?,
1769            "deterministic" | "det" | "fast" => session.compact_deterministic(),
1770            other => anyhow::bail!("compact mode must be llm or deterministic; got {other}"),
1771        };
1772        let after = session.context_status().estimate.total_tokens;
1773        crate::ui::section("Compaction");
1774        if let Some(stats) = stats {
1775            crate::ui::kv(
1776                "tokens",
1777                format_args!("{} -> {}", stats.before_tokens, stats.after_tokens),
1778            );
1779            crate::ui::kv("removed messages", stats.removed_messages);
1780            crate::ui::kv("tool outputs", stats.compacted_tools);
1781            crate::ui::kv("summarized", stats.summarized);
1782        } else {
1783            crate::ui::kv("tokens", format_args!("{before} -> {after}"));
1784            crate::ui::line("nothing to compact");
1785        }
1786        Ok(true)
1787    }
1788
1789    async fn model_command(value: Option<&str>, session: &mut Session) -> Result<bool> {
1790        if let Some(value) = value {
1791            config::save_model_config(value)?;
1792            session.model = model::resolve_model(Some(value))?;
1793        }
1794        crate::ui::line(format_args!("model: {}", session.model));
1795        Ok(true)
1796    }
1797
1798    fn thinking_command(value: Option<&str>) -> Result<bool> {
1799        if let Some(value) = value {
1800            match value {
1801                "" | "auto" => unsafe { std::env::remove_var("OY_THINKING") },
1802                "off" | "none" => unsafe { std::env::set_var("OY_THINKING", "none") },
1803                "minimal" | "low" | "medium" | "high" => unsafe {
1804                    std::env::set_var("OY_THINKING", value)
1805                },
1806                other => anyhow::bail!(
1807                    "thinking must be auto, off, minimal, low, medium, or high; got {other}"
1808                ),
1809            }
1810        }
1811        crate::ui::line(format_args!(
1812            "thinking: {}",
1813            std::env::var("OY_THINKING").unwrap_or_else(|_| "auto".to_string())
1814        ));
1815        Ok(true)
1816    }
1817
1818    fn status_command(session: &Session) -> Result<bool> {
1819        crate::ui::section("Status");
1820        crate::ui::kv("workspace", session.root.display());
1821        crate::ui::kv("model", &session.model);
1822        crate::ui::kv("genai", model::to_genai_model_spec(&session.model));
1823        crate::ui::kv(
1824            "thinking",
1825            model::default_reasoning_effort(&session.model).unwrap_or("auto/off"),
1826        );
1827        crate::ui::kv("mode", &session.mode);
1828        crate::ui::kv("interactive", crate::ui::bool_text(session.interactive));
1829        crate::ui::kv(
1830            "files-write",
1831            format_args!("{:?}", session.policy.files_write),
1832        );
1833        crate::ui::kv("shell", format_args!("{:?}", session.policy.shell));
1834        crate::ui::kv("network", crate::ui::bool_text(session.policy.network));
1835        crate::ui::kv("risk", config::policy_risk_label(&session.policy));
1836        crate::ui::kv("messages", session.transcript.messages.len());
1837        crate::ui::kv("todos", session.todos.len());
1838        let status = session.context_status();
1839        crate::ui::kv(
1840            "context",
1841            format_args!(
1842                "~{} / {} tokens",
1843                status.estimate.total_tokens, status.input_budget_tokens
1844            ),
1845        );
1846        crate::ui::kv("summary", crate::ui::bool_text(status.summary_present));
1847        Ok(true)
1848    }
1849
1850    fn save_command(name: Option<&str>, session: &mut Session) -> Result<bool> {
1851        let path = session.save(name)?;
1852        crate::ui::success(format_args!("saved session {}", path.display()));
1853        Ok(true)
1854    }
1855
1856    fn load_command(name: Option<&str>, session: &mut Session) -> Result<bool> {
1857        if let Some(new_session) =
1858            session::load_saved(name, true, session.mode.clone(), session.policy)?
1859        {
1860            *session = new_session;
1861            crate::ui::success("loaded session");
1862        } else {
1863            crate::ui::warn("no saved sessions found");
1864        }
1865        Ok(true)
1866    }
1867
1868    fn undo_command(session: &mut Session) -> Result<bool> {
1869        if session.transcript.undo_last_turn() {
1870            crate::ui::success("undid last turn");
1871        } else {
1872            crate::ui::warn("nothing to undo");
1873        }
1874        Ok(true)
1875    }
1876
1877    fn clear_command(session: &mut Session) -> Result<bool> {
1878        session.transcript.messages.clear();
1879        crate::ui::success("conversation cleared");
1880        Ok(true)
1881    }
1882
1883    async fn run_prompt_with_model_reselect(session: &mut Session, prompt: &str) -> Result<()> {
1884        loop {
1885            match session::run_prompt(session, prompt).await {
1886                Ok(answer) => {
1887                    if !answer.is_empty() {
1888                        crate::ui::markdown(&format!("{answer}\n"));
1889                    }
1890                    return Ok(());
1891                }
1892                Err(err) if config::can_prompt() => {
1893                    crate::ui::err_line(format_args!("model call failed: {err:#}"));
1894                    session.transcript.undo_last_turn();
1895                    let Some(model) = choose_replacement_model(session).await? else {
1896                        return Err(err);
1897                    };
1898                    session.model = model;
1899                    config::save_model_config(&session.model)?;
1900                    crate::ui::err_line(format_args!("retrying with model: {}", session.model));
1901                }
1902                Err(err) => return Err(err),
1903            }
1904        }
1905    }
1906
1907    async fn choose_replacement_model(session: &Session) -> Result<Option<String>> {
1908        let listing = model::inspect_models().await?;
1909        let items = replacement_model_choices(&session.model, listing.all_models, listing.hints);
1910        if items.is_empty() {
1911            return Ok(None);
1912        }
1913        choose_model(None, &items)
1914    }
1915
1916    fn replacement_model_choices(
1917        current: &str,
1918        mut models: Vec<String>,
1919        hints: Vec<String>,
1920    ) -> Vec<String> {
1921        models.extend(hints);
1922        models.retain(|item| item != current);
1923        models.sort();
1924        models.dedup();
1925        models
1926    }
1927
1928    pub fn choose_model(current: Option<&str>, items: &[String]) -> Result<Option<String>> {
1929        choose_model_with_initial_list(current, items, true)
1930    }
1931
1932    pub fn choose_model_with_initial_list(
1933        current: Option<&str>,
1934        items: &[String],
1935        _print_initial_list: bool,
1936    ) -> Result<Option<String>> {
1937        if items.is_empty() || !config::can_prompt() {
1938            return Ok(None);
1939        }
1940        let theme = ColorfulTheme::default();
1941        let default = current.and_then(|value| items.iter().position(|item| item == value));
1942        let mut prompt = Select::with_theme(&theme)
1943            .with_prompt("Models")
1944            .items(items)
1945            .default(default.unwrap_or(0));
1946        if current.is_some() {
1947            prompt = prompt.with_prompt("Models (Esc keeps current)");
1948        }
1949        Ok(prompt.interact_opt()?.map(|index| items[index].clone()))
1950    }
1951
1952    pub fn ask(question: &str, choices: Option<&[String]>) -> Result<String> {
1953        if let Some(choices) = choices {
1954            if choices.is_empty() {
1955                return Ok(String::new());
1956            }
1957            let index = Select::with_theme(&ColorfulTheme::default())
1958                .with_prompt(question)
1959                .items(choices)
1960                .default(0)
1961                .interact_opt()?;
1962            return Ok(index
1963                .map(|index| choices[index].clone())
1964                .unwrap_or_default());
1965        }
1966        Ok(Input::<String>::with_theme(&ColorfulTheme::default())
1967            .with_prompt(question)
1968            .interact_text()?)
1969    }
1970
1971    fn history_path(name: &str) -> Result<PathBuf> {
1972        history_path_in(config::config_dir_path(), name)
1973    }
1974
1975    fn history_path_in(config_dir: PathBuf, name: &str) -> Result<PathBuf> {
1976        let history = config_dir.join("history");
1977        config::create_private_dir_all(&history)?;
1978        let path = history.join(format!("{name}.txt"));
1979        if !path.exists() {
1980            config::write_private_file(&path, b"")?;
1981        }
1982        Ok(path)
1983    }
1984
1985    #[cfg(test)]
1986    mod tests {
1987        use super::*;
1988
1989        #[test]
1990        fn history_path_uses_named_private_history_file() {
1991            let dir = tempfile::tempdir().unwrap();
1992            let path = history_path_in(dir.path().to_path_buf(), "chat").unwrap();
1993            assert!(path.ends_with("history/chat.txt"));
1994            assert!(path.exists());
1995
1996            #[cfg(unix)]
1997            {
1998                use std::os::unix::fs::PermissionsExt as _;
1999                let history_dir_mode = std::fs::metadata(path.parent().unwrap())
2000                    .unwrap()
2001                    .permissions()
2002                    .mode()
2003                    & 0o777;
2004                let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2005                assert_eq!(history_dir_mode, 0o700);
2006                assert_eq!(file_mode, 0o600);
2007            }
2008        }
2009
2010        #[test]
2011        fn normalize_chat_command_maps_slash_aliases() {
2012            assert_eq!(normalize_chat_command("q"), "quit");
2013            assert_eq!(normalize_chat_command("tokens"), "tokens");
2014            assert_eq!(normalize_chat_command("k"), "compact");
2015            assert_eq!(normalize_chat_command("s"), "status");
2016        }
2017
2018        #[test]
2019        fn chat_help_uses_slash_commands() {
2020            let help = chat_help_text();
2021            assert!(help.contains("/help"));
2022            assert!(help.contains("/quit"));
2023            assert!(help.contains("/compact"));
2024            assert!(help.contains("/status"));
2025        }
2026
2027        #[test]
2028        fn replacement_model_choices_drop_current_and_dedup() {
2029            let choices = replacement_model_choices(
2030                "broken",
2031                vec!["broken".into(), "ok".into()],
2032                vec!["ok".into(), "other".into()],
2033            );
2034            assert_eq!(choices, vec!["ok".to_string(), "other".to_string()]);
2035        }
2036    }
2037}
2038
2039// === app ===
2040pub(crate) mod app {
2041    use anyhow::{Result, bail};
2042    use clap::{Args, Parser, Subcommand, ValueEnum};
2043    use std::io::IsTerminal as _;
2044    use std::path::{Path, PathBuf};
2045
2046    use crate::audit;
2047    use crate::config;
2048    use crate::model;
2049    use crate::session::{self, Session};
2050
2051    const MODEL_LIST_LIMIT: usize = 30;
2052
2053    #[derive(Debug, Parser)]
2054    #[command(
2055        name = "oy",
2056        version,
2057        about = "Small local AI coding assistant for your shell.",
2058        after_help = "Examples:\n  oy doctor\n  oy model\n  oy \"inspect this repo and summarize risks\"\n  oy chat --mode plan\n  oy run --out plan.md \"write a migration plan\"\n\nSafety: file tools stay inside the workspace, but oy is not a sandbox. Use --mode plan or a container/VM for untrusted repos."
2059    )]
2060    struct Cli {
2061        #[arg(long, global = true, conflicts_with_all = ["verbose", "json"], help = "Suppress normal progress output")]
2062        quiet: bool,
2063        #[arg(long, global = true, conflicts_with_all = ["quiet", "json"], help = "Show fuller tool previews")]
2064        verbose: bool,
2065        #[arg(long, global = true, conflicts_with_all = ["quiet", "verbose"], help = "Print machine-readable JSON where supported")]
2066        json: bool,
2067        #[command(subcommand)]
2068        command: Option<Command>,
2069    }
2070
2071    #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
2072    enum AuditFormat {
2073        Markdown,
2074        Sarif,
2075    }
2076
2077    impl From<AuditFormat> for audit::AuditOutputFormat {
2078        fn from(format: AuditFormat) -> Self {
2079            match format {
2080                AuditFormat::Markdown => Self::Markdown,
2081                AuditFormat::Sarif => Self::Sarif,
2082            }
2083        }
2084    }
2085
2086    #[derive(Debug, Subcommand)]
2087    enum Command {
2088        /// Run one task in the current workspace; prompt can be args or stdin.
2089        Run(RunArgs),
2090        /// Start an interactive chat session with slash commands and history.
2091        Chat(ChatArgs),
2092        /// List, choose, and save model ids/routing shims.
2093        Model(ModelArgs),
2094        /// Check setup, auth, paths, and safety-relevant defaults.
2095        Doctor(DoctorArgs),
2096        /// Audit the current workspace and write findings.
2097        Audit {
2098            #[arg(
2099                long,
2100                value_enum,
2101                default_value_t = AuditFormat::Markdown,
2102                help = "Output format: markdown or sarif"
2103            )]
2104            format: AuditFormat,
2105            #[arg(
2106                long,
2107                value_name = "PATH",
2108                help = "Write findings to a workspace file (default: ISSUES.md or oy.sarif)"
2109            )]
2110            out: Option<PathBuf>,
2111            #[arg(
2112                long,
2113                value_name = "N",
2114                default_value_t = audit::DEFAULT_MAX_REVIEW_CHUNKS,
2115                help = "Maximum audit chunks to review before failing closed"
2116            )]
2117            max_chunks: usize,
2118            #[arg(value_name = "FOCUS", help = "Optional audit focus text")]
2119            focus: Vec<String>,
2120        },
2121    }
2122
2123    #[derive(Debug, Args, Clone)]
2124    struct SharedModeArgs {
2125        #[arg(
2126            long,
2127            alias = "agent",
2128            default_value = "default",
2129            help = "Safety mode (default: balanced): plan, ask, edit, or auto"
2130        )]
2131        mode: String,
2132        #[arg(
2133            long = "continue-session",
2134            default_value_t = false,
2135            help = "Resume the most recent saved session"
2136        )]
2137        continue_session: bool,
2138        #[arg(
2139            long,
2140            default_value = "",
2141            value_name = "NAME_OR_NUMBER",
2142            help = "Resume a named or numbered saved session"
2143        )]
2144        resume: String,
2145    }
2146
2147    #[derive(Debug, Args, Clone)]
2148    struct RunArgs {
2149        #[command(flatten)]
2150        shared: SharedModeArgs,
2151        #[arg(
2152            long,
2153            value_name = "PATH",
2154            help = "Write the final answer to a workspace file"
2155        )]
2156        out: Option<PathBuf>,
2157        #[arg(
2158            value_name = "PROMPT",
2159            help = "Task prompt; omitted means read stdin or start chat in a TTY"
2160        )]
2161        task: Vec<String>,
2162    }
2163
2164    #[derive(Debug, Args, Clone)]
2165    struct ChatArgs {
2166        #[command(flatten)]
2167        shared: SharedModeArgs,
2168    }
2169
2170    #[derive(Debug, Args, Clone)]
2171    struct ModelArgs {
2172        #[arg(
2173            value_name = "MODEL",
2174            help = "Model id or routing shim selection, e.g. copilot::gpt-4.1-mini"
2175        )]
2176        model: Option<String>,
2177    }
2178
2179    #[derive(Debug, Args, Clone)]
2180    struct DoctorArgs {
2181        #[arg(
2182            long,
2183            alias = "agent",
2184            default_value = "default",
2185            help = "Safety mode to inspect (default: balanced): plan, ask, edit, or auto"
2186        )]
2187        mode: String,
2188    }
2189
2190    pub async fn run(argv: Vec<String>) -> Result<i32> {
2191        let normalized = normalize_args(argv);
2192        let mut cli = Cli::parse_from(std::iter::once("oy".to_string()).chain(normalized.clone()));
2193        restore_trailing_audit_options(&mut cli);
2194        crate::ui::init_output_mode(cli_output_mode(&cli));
2195        match cli.command.unwrap_or(Command::Run(RunArgs {
2196            shared: SharedModeArgs {
2197                mode: "default".to_string(),
2198                continue_session: false,
2199                resume: String::new(),
2200            },
2201            out: None,
2202            task: Vec::new(),
2203        })) {
2204            Command::Run(args) => run_command(args).await,
2205            Command::Chat(args) => chat_command(args).await,
2206            Command::Model(args) => model_command(args).await,
2207            Command::Doctor(args) => doctor_command(args).await,
2208            Command::Audit {
2209                format,
2210                out,
2211                max_chunks,
2212                focus,
2213            } => {
2214                audit_command(AuditArgs {
2215                    focus,
2216                    out: out.unwrap_or_else(|| audit::default_output_path(format.into())),
2217                    max_chunks,
2218                    format: format.into(),
2219                })
2220                .await
2221            }
2222        }
2223    }
2224
2225    fn restore_trailing_audit_options(cli: &mut Cli) {
2226        let Some(Command::Audit {
2227            format: _,
2228            out: _,
2229            max_chunks,
2230            focus,
2231        }) = &mut cli.command
2232        else {
2233            return;
2234        };
2235        let mut filtered_focus = Vec::new();
2236        let mut i = 0usize;
2237        while i < focus.len() {
2238            match focus[i].as_str() {
2239                "--max-chunks" => {
2240                    if let Some(value) = focus.get(i + 1)
2241                        && let Ok(parsed) = value.parse::<usize>()
2242                    {
2243                        *max_chunks = parsed;
2244                        i += 2;
2245                        continue;
2246                    }
2247                }
2248                raw if raw.starts_with("--max-chunks=") => {
2249                    if let Some((_, value)) = raw.split_once('=')
2250                        && let Ok(parsed) = value.parse::<usize>()
2251                    {
2252                        *max_chunks = parsed;
2253                        i += 1;
2254                        continue;
2255                    }
2256                }
2257                _ => {}
2258            }
2259            filtered_focus.push(focus[i].clone());
2260            i += 1;
2261        }
2262        *focus = filtered_focus;
2263    }
2264
2265    fn cli_output_mode(cli: &Cli) -> Option<crate::ui::OutputMode> {
2266        if cli.quiet {
2267            Some(crate::ui::OutputMode::Quiet)
2268        } else if cli.verbose {
2269            Some(crate::ui::OutputMode::Verbose)
2270        } else if cli.json {
2271            Some(crate::ui::OutputMode::Json)
2272        } else {
2273            None
2274        }
2275    }
2276
2277    #[cfg(test)]
2278    fn parse_cli_for_test(args: &[&str]) -> Cli {
2279        let mut cli = Cli::parse_from(args);
2280        restore_trailing_audit_options(&mut cli);
2281        cli
2282    }
2283
2284    #[cfg(test)]
2285    fn command_help_for_test(command: &str) -> String {
2286        let mut cmd = <Cli as clap::CommandFactory>::command();
2287        let Some(subcommand) = cmd.find_subcommand_mut(command) else {
2288            panic!("unknown command: {command}");
2289        };
2290        let mut help = Vec::new();
2291        subcommand.write_long_help(&mut help).expect("write help");
2292        String::from_utf8(help).expect("utf8 help")
2293    }
2294
2295    fn normalize_args(mut args: Vec<String>) -> Vec<String> {
2296        if args.is_empty() {
2297            return if config::can_prompt() {
2298                vec!["--help".to_string()]
2299            } else {
2300                vec!["run".to_string()]
2301            };
2302        }
2303        if matches!(
2304            args.first().map(String::as_str),
2305            Some("--continue") | Some("-c")
2306        ) {
2307            return std::iter::once("run".to_string())
2308                .chain(std::iter::once("--continue-session".to_string()))
2309                .chain(args.drain(1..))
2310                .collect();
2311        }
2312        if args.first().map(String::as_str) == Some("--resume") {
2313            return std::iter::once("run".to_string()).chain(args).collect();
2314        }
2315        let commands = ["run", "chat", "model", "doctor", "audit", "-h", "--help"];
2316        if args
2317            .first()
2318            .is_some_and(|arg| !arg.starts_with('-') && !commands.contains(&arg.as_str()))
2319        {
2320            let mut out = vec!["run".to_string()];
2321            out.extend(args);
2322            return out;
2323        }
2324        args
2325    }
2326
2327    async fn run_command(args: RunArgs) -> Result<i32> {
2328        let task = collect_task(&args.task)?;
2329        if task.trim().is_empty() {
2330            return chat_command(ChatArgs {
2331                shared: args.shared,
2332            })
2333            .await;
2334        }
2335        let mut session = load_or_new(
2336            false,
2337            &args.shared.mode,
2338            args.shared.continue_session,
2339            &args.shared.resume,
2340        )?;
2341        print_session_intro("run", &session, Some(&task));
2342        let answer = session::run_prompt(&mut session, &task).await?;
2343        if crate::ui::is_json() {
2344            print_run_json(&session, &answer)?;
2345        } else if let Some(path) = args.out {
2346            write_workspace_file(&session.root, &path, &answer)?;
2347            crate::ui::success(format_args!("wrote {}", path.display()));
2348        } else if !answer.is_empty() {
2349            crate::ui::markdown(&format!("{answer}\n"));
2350        }
2351        Ok(0)
2352    }
2353
2354    fn print_run_json(session: &Session, answer: &str) -> Result<()> {
2355        let status = session.context_status();
2356        let payload = serde_json::json!({
2357            "answer": answer,
2358            "model": session.model,
2359            "mode": session.mode,
2360            "workspace": session.root,
2361            "tokens": status.estimate,
2362            "context": status,
2363            "messages": status.estimate.messages,
2364            "todos": session.todos,
2365        });
2366        crate::ui::line(serde_json::to_string_pretty(&payload)?);
2367        Ok(())
2368    }
2369
2370    async fn chat_command(args: ChatArgs) -> Result<i32> {
2371        let mut session = load_or_new(
2372            true,
2373            &args.shared.mode,
2374            args.shared.continue_session,
2375            &args.shared.resume,
2376        )?;
2377        print_session_intro("chat", &session, None);
2378        crate::chat::run_chat(&mut session).await
2379    }
2380
2381    async fn model_command(args: ModelArgs) -> Result<i32> {
2382        if let Some(model_spec) = args
2383            .model
2384            .as_deref()
2385            .filter(|value| is_exact_model_spec(value))
2386        {
2387            let normalized = model::canonical_model_spec(model_spec);
2388            config::save_model_config(&normalized)?;
2389            if crate::ui::is_json() {
2390                print_saved_model_json(&normalized)?;
2391            } else {
2392                print_saved_model(&normalized);
2393            }
2394            return Ok(0);
2395        }
2396
2397        let listing = model::inspect_models().await?;
2398        if let Some(model_spec) = args.model {
2399            let normalized = resolve_model_choice(&listing, &model_spec)?;
2400            config::save_model_config(&normalized)?;
2401            if crate::ui::is_json() {
2402                print_model_json(&listing, Some(&normalized))?;
2403            } else {
2404                print_saved_model(&normalized);
2405            }
2406            return Ok(0);
2407        }
2408        if crate::ui::is_json() {
2409            print_model_json(&listing, None)?;
2410            return Ok(0);
2411        }
2412        print_model_listing(&listing);
2413        if config::can_prompt()
2414            && !listing.all_models.is_empty()
2415            && let Some(chosen) = crate::chat::choose_model_with_initial_list(
2416                listing.current.as_deref(),
2417                &listing.all_models,
2418                false,
2419            )?
2420        {
2421            config::save_model_config(&chosen)?;
2422            print_saved_model(&chosen);
2423        }
2424        Ok(0)
2425    }
2426
2427    fn is_exact_model_spec(value: &str) -> bool {
2428        let value = value.trim();
2429        value.contains("::") || value.contains('/') || value.contains(':') || value.contains('.')
2430    }
2431
2432    fn print_saved_model_json(saved: &str) -> Result<()> {
2433        let payload = serde_json::json!({ "saved": saved });
2434        crate::ui::line(serde_json::to_string_pretty(&payload)?);
2435        Ok(())
2436    }
2437
2438    fn print_model_json(listing: &model::ModelListing, saved: Option<&str>) -> Result<()> {
2439        let payload = serde_json::json!({
2440            "current": listing.current,
2441            "current_shim": listing.current_shim,
2442            "saved": saved,
2443            "auth": listing.auth,
2444            "recommended": listing.recommended,
2445            "dynamic": listing.dynamic,
2446            "hints": listing.hints,
2447            "all_models": listing.all_models,
2448        });
2449        crate::ui::line(serde_json::to_string_pretty(&payload)?);
2450        Ok(())
2451    }
2452
2453    fn print_model_listing(listing: &model::ModelListing) {
2454        crate::ui::section("Models");
2455        crate::ui::kv(
2456            "current",
2457            current_model_text(
2458                listing.current.as_deref().unwrap_or("<unset>"),
2459                listing.current_shim.as_deref(),
2460            ),
2461        );
2462        crate::ui::kv("selectable", listing.all_models.len());
2463        if !listing.recommended.is_empty() {
2464            crate::ui::kv("recommended", listing.recommended.join(", "));
2465            if listing.current.is_none() {
2466                crate::ui::line(format_args!("  Try: oy model {}", listing.recommended[0]));
2467            }
2468        }
2469
2470        if !listing.auth.is_empty() {
2471            crate::ui::line("");
2472            crate::ui::section("Auth / shims");
2473            for item in &listing.auth {
2474                let env_var = item.env_var.as_deref().unwrap_or("-");
2475                let active = if listing.current_shim.as_deref() == Some(item.adapter.as_str()) {
2476                    " *"
2477                } else {
2478                    ""
2479                };
2480                crate::ui::line(format_args!(
2481                    "  {}{}  {} ({})",
2482                    item.adapter, active, env_var, item.source
2483                ));
2484                crate::ui::line(format_args!("    {}", item.detail));
2485            }
2486        }
2487
2488        crate::ui::line("");
2489        crate::ui::section("Introspected endpoint models");
2490        if listing.dynamic.is_empty() {
2491            crate::ui::line("  none found from configured OpenAI-compatible endpoints");
2492        } else {
2493            for item in &listing.dynamic {
2494                if !item.ok {
2495                    crate::ui::line(format_args!(
2496                        "  {}  failed via {}",
2497                        item.adapter, item.source
2498                    ));
2499                    if let Some(error) = item.error.as_deref() {
2500                        crate::ui::line(format_args!(
2501                            "    {}",
2502                            crate::ui::truncate_chars(error, 140)
2503                        ));
2504                    }
2505                    continue;
2506                }
2507                crate::ui::line(format_args!(
2508                    "  {}  {} models via {}",
2509                    item.adapter, item.count, item.source
2510                ));
2511                for model_name in item.models.iter().take(MODEL_LIST_LIMIT) {
2512                    let marker = if listing.current.as_deref() == Some(model_name.as_str()) {
2513                        "*"
2514                    } else {
2515                        " "
2516                    };
2517                    crate::ui::line(format_args!("    {marker} {model_name}"));
2518                }
2519                if item.models.len() > MODEL_LIST_LIMIT {
2520                    crate::ui::line(format_args!(
2521                        "    … {} more; use `oy model <filter>` or interactive selection",
2522                        item.models.len() - MODEL_LIST_LIMIT
2523                    ));
2524                }
2525            }
2526        }
2527
2528        let hinted = listing
2529            .hints
2530            .iter()
2531            .filter(|hint| {
2532                !listing
2533                    .dynamic
2534                    .iter()
2535                    .any(|group| group.models.iter().any(|model| model == *hint))
2536            })
2537            .collect::<Vec<_>>();
2538        if !hinted.is_empty() {
2539            crate::ui::line("");
2540            crate::ui::section("Built-in selectable hints");
2541            for hint in hinted.iter().take(MODEL_LIST_LIMIT) {
2542                crate::ui::line(format_args!("  {hint}"));
2543            }
2544            if hinted.len() > MODEL_LIST_LIMIT {
2545                crate::ui::line(format_args!(
2546                    "  … {} more hints",
2547                    hinted.len() - MODEL_LIST_LIMIT
2548                ));
2549            }
2550        }
2551    }
2552
2553    fn current_model_text(model_spec: &str, shim: Option<&str>) -> String {
2554        match shim.filter(|value| !value.is_empty()) {
2555            Some(shim) => format!("{model_spec} (shim: {shim})"),
2556            None => model_spec.to_string(),
2557        }
2558    }
2559
2560    fn print_saved_model(selection: &str) {
2561        let saved = config::saved_model_config_from_selection(selection);
2562        crate::ui::success(format_args!(
2563            "saved model {}",
2564            saved.model.as_deref().unwrap_or(selection)
2565        ));
2566        if let Some(shim) = saved.shim {
2567            crate::ui::kv("shim", shim);
2568        }
2569    }
2570
2571    fn resolve_model_choice(listing: &model::ModelListing, query: &str) -> Result<String> {
2572        let normalized = model::canonical_model_spec(query);
2573        if listing.all_models.iter().any(|item| item == &normalized) {
2574            return Ok(normalized);
2575        }
2576        if !config::can_prompt() {
2577            bail!(
2578                "No exact model match for `{}`. Re-run in a TTY to choose interactively.",
2579                query
2580            );
2581        }
2582        let matches = listing
2583            .all_models
2584            .iter()
2585            .filter(|item| {
2586                item.to_ascii_lowercase()
2587                    .contains(&query.to_ascii_lowercase())
2588            })
2589            .cloned()
2590            .collect::<Vec<_>>();
2591        if matches.is_empty() {
2592            bail!("No matching model for `{}`", query);
2593        }
2594        crate::chat::choose_model(listing.current.as_deref(), &matches)
2595            .map(|value| value.unwrap_or(normalized))
2596    }
2597
2598    async fn doctor_command(args: DoctorArgs) -> Result<i32> {
2599        let root = config::oy_root()?;
2600        let listing = model::inspect_models().await?;
2601        let mode = config::safety_mode(&args.mode)?;
2602        let policy = config::tool_policy(mode.name());
2603        let config_file = config::config_root();
2604        let config_dir = config::config_dir_path();
2605        let sessions_dir = config::sessions_dir().unwrap_or_else(|_| config_dir.join("sessions"));
2606        let history_dir = config_dir.join("history");
2607        let bash_ok = std::process::Command::new("bash")
2608            .arg("--version")
2609            .stdout(std::process::Stdio::null())
2610            .stderr(std::process::Stdio::null())
2611            .status()
2612            .map(|status| status.success())
2613            .unwrap_or(false);
2614
2615        if crate::ui::is_json() {
2616            let payload = serde_json::json!({
2617                "workspace": root,
2618                "model": listing.current,
2619                "shim": listing.current_shim,
2620                "auth": listing.auth,
2621                "mode": mode.name(),
2622                "policy": policy,
2623                "interactive": config::can_prompt(),
2624                "non_interactive": config::non_interactive(),
2625                "config_file": config_file,
2626                "config_dir": config_dir,
2627                "sessions_dir": sessions_dir,
2628                "history_dir": history_dir,
2629                "bash": bash_ok,
2630                "recommended": listing.recommended,
2631                "next_step": recommended_next_step(&listing),
2632            });
2633            crate::ui::line(serde_json::to_string_pretty(&payload)?);
2634            return Ok(0);
2635        }
2636
2637        crate::ui::section("Doctor");
2638        crate::ui::kv("workspace", root.display());
2639        crate::ui::kv("model", listing.current.as_deref().unwrap_or("<unset>"));
2640        crate::ui::kv("shim", listing.current_shim.as_deref().unwrap_or("<none>"));
2641        crate::ui::kv("mode", mode.name());
2642        crate::ui::kv("files-write", format_args!("{:?}", policy.files_write));
2643        crate::ui::kv("shell", format_args!("{:?}", policy.shell));
2644        crate::ui::kv("network", crate::ui::bool_text(policy.network));
2645        crate::ui::kv("risk", config::policy_risk_label(&policy));
2646        crate::ui::kv("interactive", crate::ui::bool_text(config::can_prompt()));
2647        crate::ui::kv(
2648            "bash",
2649            crate::ui::status_text(bash_ok, if bash_ok { "ok" } else { "missing" }),
2650        );
2651        crate::ui::line("");
2652        crate::ui::section("Local state");
2653        crate::ui::kv("config", config_file.display());
2654        crate::ui::kv("sessions", sessions_dir.display());
2655        crate::ui::kv("history", history_dir.display());
2656        crate::ui::line(
2657            "  Treat local state as sensitive: prompts, source snippets, tool output, and command output may be saved.",
2658        );
2659        crate::ui::line("");
2660        crate::ui::section("Auth / shims");
2661        if listing.auth.is_empty() {
2662            crate::ui::warn("no provider auth detected");
2663        } else {
2664            for item in &listing.auth {
2665                crate::ui::line(format_args!(
2666                    "  {}  {} ({})",
2667                    item.adapter,
2668                    item.env_var.as_deref().unwrap_or("-"),
2669                    item.source
2670                ));
2671                crate::ui::line(format_args!("    {}", item.detail));
2672            }
2673        }
2674        if listing.current.is_none() {
2675            crate::ui::line("");
2676            crate::ui::warn("no model configured");
2677            crate::ui::line(format_args!("  {}", recommended_next_step(&listing)));
2678        }
2679        crate::ui::line("");
2680        crate::ui::section("Recommended next steps");
2681        crate::ui::line(format_args!("  1. {}", recommended_next_step(&listing)));
2682        crate::ui::line("  2. For untrusted repos: `oy chat --mode plan`");
2683        crate::ui::line(format_args!(
2684            "  • Read-only container: {}",
2685            safe_container_command(&root, true)
2686        ));
2687        crate::ui::line("");
2688        crate::ui::section("Safety");
2689        crate::ui::line(
2690            "  oy is not a sandbox. Use `oy chat --mode plan` or a disposable container/VM for untrusted repos.",
2691        );
2692        crate::ui::line(
2693            "  Mount only needed credentials/env vars. Do not mount the host Docker socket into AI-assisted containers.",
2694        );
2695        Ok(0)
2696    }
2697
2698    fn recommended_next_step(listing: &model::ModelListing) -> String {
2699        if listing.current.is_some() {
2700            return "Run `oy \"inspect this repo\"` or `oy chat`.".to_string();
2701        }
2702        if let Some(choice) = listing.recommended.first() {
2703            return format!("Configure a model: `oy model {choice}`.");
2704        }
2705        "Configure provider auth, then run `oy model`; see `oy doctor` output.".to_string()
2706    }
2707
2708    fn safe_container_command(root: &Path, read_only: bool) -> String {
2709        let mode = if read_only { "ro" } else { "rw" };
2710        format!(
2711            "docker run --rm -it -v \"{}:/workspace:{mode}\" -w /workspace oy-image oy chat --mode plan",
2712            root.display()
2713        )
2714    }
2715
2716    #[derive(Debug, Clone)]
2717    struct AuditArgs {
2718        focus: Vec<String>,
2719        out: PathBuf,
2720        max_chunks: usize,
2721        format: audit::AuditOutputFormat,
2722    }
2723
2724    async fn audit_command(args: AuditArgs) -> Result<i32> {
2725        let started = std::time::Instant::now();
2726        let focus = args.focus.join(" ");
2727        let root = config::oy_root()?;
2728        let model = model::resolve_model(None)?;
2729        if !crate::ui::is_quiet() {
2730            crate::ui::section("audit");
2731            crate::ui::kv("workspace", root.display());
2732            crate::ui::kv("model", &model);
2733            crate::ui::kv("mode", "no-tools");
2734            crate::ui::kv("format", args.format.name());
2735            crate::ui::kv("out", args.out.display());
2736            crate::ui::kv("max chunks", args.max_chunks);
2737            if !focus.trim().is_empty() {
2738                crate::ui::kv("focus", crate::ui::compact_preview(&focus, 100));
2739            }
2740        }
2741        let result = audit::run(audit::AuditOptions {
2742            root,
2743            model,
2744            focus,
2745            out: args.out,
2746            max_chunks: args.max_chunks,
2747            format: args.format,
2748        })
2749        .await?;
2750        if crate::ui::is_json() {
2751            let payload = serde_json::json!({
2752                "output": result.output_path,
2753                "files": result.file_count,
2754                "chunks": result.chunk_count,
2755                "format": args.format.name(),
2756                "elapsed_ms": started.elapsed().as_millis(),
2757            });
2758            crate::ui::line(serde_json::to_string_pretty(&payload)?);
2759        } else {
2760            crate::ui::success(format_args!(
2761                "wrote {} ({} files, {} chunks, {})",
2762                result.output_path.display(),
2763                result.file_count,
2764                result.chunk_count,
2765                crate::ui::format_duration(started.elapsed())
2766            ));
2767        }
2768        Ok(0)
2769    }
2770
2771    fn load_or_new(
2772        interactive: bool,
2773        mode_name: &str,
2774        continue_session: bool,
2775        resume: &str,
2776    ) -> Result<Session> {
2777        let mode = config::safety_mode(mode_name)?;
2778        let policy = config::tool_policy(mode.name());
2779        if continue_session || !resume.is_empty() {
2780            let name = if continue_session { None } else { Some(resume) };
2781            if let Some(session) =
2782                session::load_saved(name, interactive, mode.name().to_string(), policy)?
2783            {
2784                return Ok(session);
2785            }
2786        }
2787        let root = config::oy_root()?;
2788        let model = model::resolve_model(None)?;
2789        Ok(Session::new(
2790            root,
2791            model,
2792            interactive,
2793            mode.name().to_string(),
2794            policy,
2795        ))
2796    }
2797
2798    fn collect_task(parts: &[String]) -> Result<String> {
2799        if !parts.is_empty() {
2800            return Ok(parts.join(" "));
2801        }
2802        if std::io::stdin().is_terminal() {
2803            return Ok(String::new());
2804        }
2805        let mut input = String::new();
2806        use std::io::Read as _;
2807        std::io::stdin().read_to_string(&mut input)?;
2808        Ok(input.trim().to_string())
2809    }
2810
2811    fn print_session_intro(mode: &str, session: &Session, prompt: Option<&str>) {
2812        if crate::ui::is_quiet() {
2813            return;
2814        }
2815        crate::ui::section(mode);
2816        crate::ui::kv("workspace", session.root.display());
2817        crate::ui::kv("model", &session.model);
2818        crate::ui::kv("mode", &session.mode);
2819        crate::ui::kv("risk", config::policy_risk_label(&session.policy));
2820        if let Some(prompt) = prompt {
2821            crate::ui::kv("prompt", crate::ui::compact_preview(prompt, 100));
2822        }
2823    }
2824
2825    fn write_workspace_file(root: &Path, requested: &Path, body: &str) -> Result<()> {
2826        let path = config::resolve_workspace_output_path(root, requested)?;
2827        let mut out = body.trim_end().to_string();
2828        out.push('\n');
2829        config::write_workspace_file(&path, out.as_bytes())
2830    }
2831
2832    #[cfg(test)]
2833    mod audit_tests {
2834        use super::*;
2835
2836        #[test]
2837        fn audit_accepts_max_chunks_flag() {
2838            let cli = parse_cli_for_test(&["oy", "audit", "--max-chunks", "240", "auth paths"]);
2839            let Some(Command::Audit {
2840                max_chunks, focus, ..
2841            }) = cli.command
2842            else {
2843                panic!("expected audit command");
2844            };
2845            assert_eq!(max_chunks, 240);
2846            assert_eq!(focus, vec!["auth paths"]);
2847        }
2848
2849        #[test]
2850        fn help_documents_audit_options() {
2851            let help = command_help_for_test("audit");
2852            assert!(help.contains("--max-chunks <N>"));
2853            assert!(help.contains("--format <FORMAT>"));
2854        }
2855
2856        #[test]
2857        fn audit_accepts_sarif_format() {
2858            let cli = parse_cli_for_test(&["oy", "audit", "--format", "sarif", "auth paths"]);
2859            let Some(Command::Audit { format, out, .. }) = cli.command else {
2860                panic!("expected audit command");
2861            };
2862            assert_eq!(format, AuditFormat::Sarif);
2863            assert_eq!(out, None);
2864        }
2865
2866        #[test]
2867        fn exact_model_specs_are_endpoint_qualified_or_provider_ids() {
2868            assert!(is_exact_model_spec("copilot::gpt-4.1-mini"));
2869            assert!(is_exact_model_spec("openai/gpt-4.1-mini"));
2870            assert!(is_exact_model_spec(
2871                "bedrock::global.amazon.nova-2-lite-v1:0"
2872            ));
2873            assert!(!is_exact_model_spec("gpt"));
2874            assert!(!is_exact_model_spec("nova"));
2875        }
2876    }
2877}