1use serde::{Deserialize, Serialize};
11
12use crate::osc::Marks;
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Source {
18 Exact,
20 Heuristic,
22}
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum InputKind {
28 Password,
31 Confirm,
33 Prompt,
35}
36
37#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
39pub struct InputExpected {
40 pub kind: InputKind,
41 pub evidence: String,
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
47pub struct Exit {
48 pub code: Option<i32>,
49 pub signal: Option<i32>,
50}
51
52#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
54pub struct State {
55 pub running: bool,
57 pub input_expected: Option<InputExpected>,
59 pub exit: Option<Exit>,
61 pub source: Source,
63}
64
65#[derive(Clone, Copy)]
67pub struct ClassifyInput<'a> {
68 pub marks: Marks,
69 pub echo: bool,
71 pub quiet: bool,
73 pub last_line: &'a str,
75}
76
77pub fn classify(input: ClassifyInput<'_>) -> State {
79 let password = !input.echo && input.marks.running;
86 let input_expected = if password {
87 Some(InputExpected {
88 kind: InputKind::Password,
89 evidence: "the terminal has ECHO off while a command runs, so the program is reading \
90 a secret"
91 .to_owned(),
92 })
93 } else if let Some(evidence) = confirm_evidence(input.last_line) {
94 Some(InputExpected { kind: InputKind::Confirm, evidence })
95 } else if input.quiet && is_prompt_shaped(input.last_line) {
96 Some(InputExpected {
97 kind: InputKind::Prompt,
98 evidence: format!(
99 "the screen is quiet at a prompt-shaped line: {}",
100 input.last_line.trim()
101 ),
102 })
103 } else {
104 None
105 };
106
107 if input.marks.seen {
108 let exit = if input.marks.running {
109 None
110 } else {
111 input.marks.last_exit.map(|code| Exit { code: Some(code), signal: None })
112 };
113 return State { running: input.marks.running, input_expected, exit, source: Source::Exact };
114 }
115
116 let at_prompt = input.quiet && is_prompt_shaped(input.last_line);
119 State { running: !at_prompt, input_expected, exit: None, source: Source::Heuristic }
120}
121
122fn confirm_evidence(line: &str) -> Option<String> {
124 let trimmed = line.trim_end();
125 let lower = trimmed.to_ascii_lowercase();
126 let asks = lower.ends_with("[y/n]")
127 || lower.ends_with("[y/n]?")
128 || lower.ends_with("(y/n)")
129 || lower.ends_with("(yes/no)")
130 || lower.ends_with("continue?")
131 || lower.ends_with("are you sure?")
132 || lower.ends_with("proceed?")
133 || lower.ends_with("overwrite?");
134 asks.then(|| format!("the last line asks a yes/no question: {}", trimmed.trim()))
135}
136
137fn is_prompt_shaped(line: &str) -> bool {
139 let trimmed = line.trim_end();
140 if trimmed.is_empty() {
141 return false;
142 }
143 let last = trimmed.chars().last().unwrap_or(' ');
144 if !matches!(last, '$' | '#' | '%' | '>' | '❯' | 'λ') {
145 return false;
146 }
147 trimmed.chars().count() <= 200
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::osc::Mark;
156
157 fn marks(seen: bool, running: bool, last_exit: Option<i32>) -> Marks {
158 Marks { seen, running, last_exit, last: None }
159 }
160
161 fn input(marks: Marks, echo: bool, quiet: bool, last_line: &str) -> ClassifyInput<'_> {
162 ClassifyInput { marks, echo, quiet, last_line }
163 }
164
165 #[test]
169 fn echo_off_while_a_command_runs_is_a_password() {
170 let state = classify(input(marks(true, true, None), false, true, "Password: "));
171 let expected = state.input_expected.expect("a password is expected");
172 assert_eq!(expected.kind, InputKind::Password);
173 assert!(expected.evidence.contains("ECHO off"), "{}", expected.evidence);
174 }
175
176 #[test]
180 fn echo_off_at_an_idle_prompt_is_not_a_password() {
181 let state = classify(input(marks(true, false, Some(0)), false, true, "ved@host:~ $ "));
182 assert_ne!(
183 state.input_expected.map(|expected| expected.kind),
184 Some(InputKind::Password),
185 "an idle prompt is the shell waiting, not a secret",
186 );
187 }
188
189 #[test]
191 fn a_yes_no_line_is_a_confirm() {
192 let state =
193 classify(input(marks(false, false, None), true, true, "Overwrite file? [y/N] "));
194 let expected = state.input_expected.expect("a confirm is expected");
195 assert_eq!(expected.kind, InputKind::Confirm);
196 assert!(expected.evidence.contains("[y/N]"), "{}", expected.evidence);
197 }
198
199 #[test]
202 fn a_quiet_prompt_is_a_prompt_and_says_heuristic() {
203 let state = classify(input(marks(false, false, None), true, true, "ved@host:~/p $ "));
204 assert_eq!(state.source, Source::Heuristic);
205 assert!(!state.running, "a quiet prompt is not a running command");
206 assert_eq!(state.input_expected.expect("a prompt").kind, InputKind::Prompt);
207 }
208
209 #[test]
212 fn the_marks_are_exact() {
213 let running = classify(input(marks(true, true, None), true, false, ""));
214 assert_eq!(running.source, Source::Exact);
215 assert!(running.running);
216 assert_eq!(running.exit, None);
217
218 let done = classify(input(marks(true, false, Some(7)), true, true, "$ "));
219 assert_eq!(done.source, Source::Exact);
220 assert!(!done.running);
221 assert_eq!(done.exit, Some(Exit { code: Some(7), signal: None }));
222 }
223
224 #[test]
227 fn a_long_line_ending_in_a_sigil_is_not_a_prompt() {
228 let prose = format!("{} >", "a long sentence that happens to end in a bracket ".repeat(6));
229 let state = classify(input(marks(false, false, None), true, true, &prose));
230 assert!(state.input_expected.is_none(), "prose is not a prompt");
231 assert!(state.running, "and the fallback reads it as work in progress");
232 }
233
234 #[test]
236 fn the_last_mark_is_carried() {
237 let mut m = marks(true, false, Some(0));
238 m.last = Some(Mark::CommandEnd(Some(0)));
239 assert_eq!(m.last, Some(Mark::CommandEnd(Some(0))));
240 }
241}