1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
//! Parses the code-workspace slash-command in the input box
//! (`/project attach <dir>`, `/project detach`, `/project status`, and the three
//! command slots — `/project build-cmd|run-cmd|test-cmd [line]`,
//! `/project clear build|run|test`). Pure, testable logic: the chat screen calls
//! it on send; a recognized command turns into an intent, an unrecognized string
//! goes out as an ordinary message.
//! Error messages are localized in the interface language (axis B) — the caller
//! passes its `Locale`.
//!
//! It has a module of its own rather than a row in `features/ui_command.rs`
//! because it takes a free-form path: the registry there models closed
//! vocabularies, and a directory may contain spaces (the `/file`, `/image` and
//! `/export` precedent). The part every such parser shares — splitting the head,
//! checking the first token, joining the tail — lives in [`super::slash`], so
//! this file holds only what is actually specific to `/project`.
//!
//! [`ProjectProgress`] is the command-outcome type, defined here in `features`
//! so both `app` (emits the events) and `screens` (renders the notes) can use it
//! without breaking FSD's dependency direction — the arrangement `FileProgress`
//! already uses.
//!
//! See docs/history/code-workspace.md, spec §9.12.
use crate::entities::workspace::CommandSlot;
use crate::shared::i18n::Locale;
/// A recognized `/project` command.
#[derive(Debug, Clone, PartialEq)]
pub enum ProjectCommand {
/// Attach a project directory to the current chat.
Attach { path: String },
/// Detach the project from the current chat.
Detach,
/// Report what is attached to the current chat.
Status,
/// Set, show or clear one command slot. One variant rather than three,
/// because everything downstream of it — the intent, the command, the
/// orchestrator handler, the note — is one path parameterized by the slot
/// and the action (design fork F3).
Slot {
slot: CommandSlot,
action: SlotAction,
},
}
/// What a `/project <slot>-cmd …` or `/project clear <slot>` asks for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SlotAction {
/// Store this line, verbatim.
Set(String),
/// Report what is stored.
Show,
/// Store nothing.
Clear,
}
/// Outcome of a `/project` command, for the feed note.
#[derive(Debug, Clone, PartialEq)]
pub enum ProjectProgress {
/// A project was attached: the canonical root and its last component.
Attached { root: String, name: String },
/// The project was detached (`root` — what it had been).
Detached { root: String },
/// `/project status`: the attached root, or `None` for "nothing attached",
/// plus every slot and what it holds. The slot list is always all three —
/// a status that hides the empty ones cannot be read as "these are the
/// commands the assistant has", which is the question being asked.
Status {
root: Option<String>,
commands: Vec<(CommandSlot, Option<String>)>,
},
/// One slot was set to `line`.
CommandSet { slot: CommandSlot, line: String },
/// One slot's current line, or `None` when it has none.
CommandShown {
slot: CommandSlot,
line: Option<String>,
},
/// One slot was unset (`had` — whether there was anything to unset).
CommandCleared { slot: CommandSlot, had: bool },
/// The line was refused: it contains shell syntax this application does not
/// run (`char`), and the answer names the route that does work.
CommandRefused { line: String, ch: char },
/// The command failed (no such directory, not a directory, no active chat…).
Failed(String),
}
/// Tries to parse an input string as a `/project` command.
///
/// - `None` — not a `/project` command: send it as an ordinary message.
/// - `Some(Ok(cmd))` — a correct command.
/// - `Some(Err(msg))` — a `/project` command with a syntax error (a localized
/// hint in `msg`, naming the usage that works).
pub fn parse(input: &str, loc: &Locale) -> Option<Result<ProjectCommand, String>> {
let usage = || loc.t("ui.project.usage");
let (sub, rest) = match crate::features::slash::head(input, "project")? {
crate::features::slash::Head::Bare => {
return Some(Err(
loc.tf("ui.project.err.missing_subcommand", &[("usage", usage())])
));
}
crate::features::slash::Head::Sub { sub, rest } => (sub, rest),
};
// A path argument is required by `attach` alone; the other two take none, so
// a missing one is only ever that command's error.
let attach = || match crate::features::slash::argument(&rest) {
Some(path) => Ok(ProjectCommand::Attach { path }),
None => Err(loc.tf("ui.project.err.missing_path", &[("usage", usage())])),
};
let sub_lower = sub.to_ascii_lowercase();
// The three slots are one subcommand with three names: `<slot>-cmd`. Written
// as a lookup rather than three arms, because three arms is where the
// fourth-slot copy-paste bug lives, and because the slot vocabulary already
// exists (`CommandSlot::ALL`).
if let Some(slot) = sub_lower.strip_suffix("-cmd").and_then(CommandSlot::parse) {
let action = match crate::features::slash::argument(&rest) {
// A line is stored **verbatim**, quotes and all: it is a command
// line, not a path, and `argument` strips the outer quotes a path
// needs. `"cargo test"` as a whole-line quote would otherwise become
// one program named `cargo test`.
Some(_) => SlotAction::Set(rest.join(" ").trim().to_string()),
None => SlotAction::Show,
};
return Some(Ok(ProjectCommand::Slot { slot, action }));
}
Some(match sub_lower.as_str() {
"attach" => attach(),
"detach" => Ok(ProjectCommand::Detach),
"status" => Ok(ProjectCommand::Status),
// Clearing is its own subcommand rather than "set to an empty line": a
// command line can legitimately be any string, so an empty argument is
// ambiguous where a word is not (design §3.1).
"clear" => match crate::features::slash::argument(&rest).as_deref() {
Some(word) => match CommandSlot::parse(word) {
Some(slot) => Ok(ProjectCommand::Slot {
slot,
action: SlotAction::Clear,
}),
None => Err(loc.tf(
"ui.project.err.unknown_slot",
&[("slot", word), ("usage", usage())],
)),
},
None => Err(loc.tf("ui.project.err.missing_slot", &[("usage", usage())])),
},
_ => Err(loc.tf(
"ui.project.err.unknown_subcommand",
&[("sub", sub), ("usage", usage())],
)),
})
}
#[cfg(test)]
mod tests {
use super::*;
/// A reference locale (ru) for parse tests — the wording is not asserted
/// here (that is `errors_name_a_route_in_every_language`), only the shape.
fn ru() -> &'static Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
}
#[test]
fn parses_the_three_subcommands() {
assert_eq!(
parse("/project attach D:/proj", ru()).unwrap().unwrap(),
ProjectCommand::Attach {
path: "D:/proj".into()
}
);
assert_eq!(
parse("/project detach", ru()).unwrap().unwrap(),
ProjectCommand::Detach
);
assert_eq!(
parse("/project status", ru()).unwrap().unwrap(),
ProjectCommand::Status
);
}
/// A path with spaces is the common case on Windows, and "copy as path"
/// wraps it in quotes.
#[test]
fn a_path_may_contain_spaces_and_quotes() {
let cmd = parse(r#"/project attach "C:\My Projects\app""#, ru())
.unwrap()
.unwrap();
assert_eq!(
cmd,
ProjectCommand::Attach {
path: r"C:\My Projects\app".into()
}
);
}
/// The three slots and the clear word, in one loop over the vocabulary: a
/// per-slot test trio is the sliding self-duplicate the gate reads as one
/// block written three times (docs/lessons.md §2), and it is also how a
/// fourth slot would get forgotten.
#[test]
fn every_slot_sets_shows_and_clears() {
for slot in CommandSlot::ALL {
let key = slot.key();
assert_eq!(
parse(&format!("/project {key}-cmd cargo build --offline"), ru())
.unwrap()
.unwrap(),
ProjectCommand::Slot {
slot,
action: SlotAction::Set("cargo build --offline".into())
}
);
assert_eq!(
parse(&format!("/project {key}-cmd"), ru())
.unwrap()
.unwrap(),
ProjectCommand::Slot {
slot,
action: SlotAction::Show
}
);
assert_eq!(
parse(&format!("/project clear {key}"), ru())
.unwrap()
.unwrap(),
ProjectCommand::Slot {
slot,
action: SlotAction::Clear
}
);
}
}
/// A command line is stored as typed. The parser that handles `/project
/// attach` strips outer quotes because a Windows path arrives quoted — and
/// doing that to a command line would turn `"cargo test"` into a single
/// program with a space in its name.
#[test]
fn a_command_line_keeps_its_own_quoting() {
assert_eq!(
parse(r#"/project test-cmd cargo test --test "my thing""#, ru())
.unwrap()
.unwrap(),
ProjectCommand::Slot {
slot: CommandSlot::Test,
action: SlotAction::Set(r#"cargo test --test "my thing""#.into())
}
);
}
#[test]
fn a_non_command_is_left_alone() {
assert!(parse("project attach x", ru()).is_none());
assert!(parse("tell me about /project", ru()).is_none());
// A longer word that merely starts the same must not be swallowed.
assert!(parse("/projects", ru()).is_none());
}
#[test]
fn subcommands_are_case_insensitive() {
assert_eq!(
parse("/PROJECT Detach", ru()).unwrap().unwrap(),
ProjectCommand::Detach
);
}
/// Every refusal must name the route that works, or the user is left
/// guessing at a command they already tried (docs/lessons.md §4).
#[test]
fn errors_name_a_route_in_every_language() {
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
let usage = loc.t("ui.project.usage");
for input in [
"/project",
"/project attach",
"/project frobnicate",
"/project clear",
"/project clear frobnicate",
] {
let Some(Err(msg)) = parse(input, loc) else {
panic!("{lang:?}: {input} must be a localized refusal");
};
assert!(
msg.contains(usage),
"{lang:?}: {input} must name the usage: {msg}"
);
assert!(
!msg.contains('{'),
"{lang:?}: unsubstituted placeholder in {msg}"
);
}
}
}
}