1pub mod cli;
2mod diff;
3mod error;
4mod git;
5mod model;
6mod patch;
7mod resolve;
8mod scan;
9mod select;
10
11use std::path::PathBuf;
12
13use cli::{Cli, Command, CommitArgs, MutateArgs, ResolveArgs, ScanArgs, ShowArgs};
14use error::{AppError, AppResult};
15use model::{ChangeView, HunkView, ScanState, SelectionPlan, SnapshotOutput};
16use select::{HunkSelector, SelectionInput};
17use serde::Serialize;
18
19pub use error::AppError as Error;
20
21pub fn run(cli: Cli) -> AppResult<CommandOutput> {
22 let repo_root = git::repo_root(&std::env::current_dir().map_err(AppError::io)?)?;
23
24 match cli.command {
25 Command::Scan(args) => scan_command(&repo_root, args),
26 Command::Show(args) => show_command(&repo_root, args),
27 Command::Resolve(args) => resolve_command(&repo_root, args),
28 Command::Stage(args) => mutate_command(&repo_root, args, false),
29 Command::Unstage(args) => mutate_command(&repo_root, args, true),
30 Command::Commit(args) => commit_command(&repo_root, args),
31 }
32}
33
34fn scan_command(repo_root: &PathBuf, args: ScanArgs) -> AppResult<CommandOutput> {
35 let state = scan::scan_repo(repo_root, args.mode)?;
36 Ok(CommandOutput::Scan(SnapshotOutput::from_snapshot(
37 state.snapshot,
38 args.compact,
39 )))
40}
41
42fn show_command(repo_root: &PathBuf, args: ShowArgs) -> AppResult<CommandOutput> {
43 let state = scan::scan_repo(repo_root, args.mode)?;
44
45 if let Some((file, hunk)) = state.find_hunk(&args.id) {
46 return Ok(CommandOutput::Show(ShowResponse::Hunk {
47 snapshot_id: state.snapshot.snapshot_id.clone(),
48 mode: state.snapshot.mode,
49 path: file.path.clone(),
50 status: file.status,
51 hunk: hunk.clone(),
52 }));
53 }
54
55 if let Some((file, change)) = state.find_change(&args.id) {
56 return Ok(CommandOutput::Show(ShowResponse::Change {
57 snapshot_id: state.snapshot.snapshot_id.clone(),
58 mode: state.snapshot.mode,
59 path: file.path.clone(),
60 status: file.status,
61 change: change.clone(),
62 }));
63 }
64
65 if let Some((file, change)) = state.find_change_key(&args.id) {
66 return Ok(CommandOutput::Show(ShowResponse::Change {
67 snapshot_id: state.snapshot.snapshot_id.clone(),
68 mode: state.snapshot.mode,
69 path: file.path.clone(),
70 status: file.status,
71 change: change.clone(),
72 }));
73 }
74
75 Err(AppError::new(
76 "unknown_id",
77 format!("no hunk or change found for id '{}'", args.id),
78 ))
79}
80
81fn resolve_command(repo_root: &PathBuf, args: ResolveArgs) -> AppResult<CommandOutput> {
82 let selection = SelectionInput {
83 snapshot_id: Some(args.snapshot),
84 hunks: Vec::new(),
85 change_ids: Vec::new(),
86 change_keys: Vec::new(),
87 };
88 let state = validate_snapshot(repo_root, args.mode, &selection)?;
89 let response = resolve::resolve_region(
90 &state,
91 &args.path,
92 args.start,
93 args.end.unwrap_or(args.start),
94 args.side,
95 )?;
96 Ok(CommandOutput::Resolve(response))
97}
98
99fn mutate_command(
100 repo_root: &PathBuf,
101 args: MutateArgs,
102 reverse: bool,
103) -> AppResult<CommandOutput> {
104 let mode = if reverse {
105 cli::Mode::Unstage
106 } else {
107 cli::Mode::Stage
108 };
109 let selection = load_selection_input(
110 args.snapshot,
111 args.plan,
112 args.hunks,
113 args.changes,
114 args.change_keys,
115 )?;
116 let state = validate_snapshot(repo_root, mode, &selection)?;
117 let resolved = select::resolve_selection(&state, &selection)?;
118 let patch = patch::build_patch(&state, &resolved)?;
119
120 git::apply_patch(repo_root, &patch, reverse)?;
121
122 let next_state = scan::scan_repo(repo_root, mode)?;
123 Ok(CommandOutput::Mutation(MutationResponse {
124 action: if reverse { "unstage" } else { "stage" },
125 snapshot_id: next_state.snapshot.snapshot_id.clone(),
126 mode,
127 selected_hunks: resolved.selected_hunks,
128 selected_changes: resolved.selected_changes,
129 selected_change_keys: resolved.selected_change_keys,
130 selected_line_ranges: resolved.selected_line_ranges,
131 snapshot: SnapshotOutput::from_snapshot(next_state.snapshot, args.compact),
132 }))
133}
134
135fn commit_command(repo_root: &PathBuf, args: CommitArgs) -> AppResult<CommandOutput> {
136 if args.messages.is_empty() {
137 return Err(AppError::new(
138 "missing_message",
139 "commit requires at least one message".to_string(),
140 ));
141 }
142
143 let selection = load_selection_input(
144 args.snapshot,
145 args.plan,
146 args.hunks,
147 args.changes,
148 args.change_keys,
149 )?;
150 let prepared = prepare_commit_selection(repo_root, &selection)?;
151
152 if args.dry_run {
153 let preview = git::preview_commit(repo_root, prepared.patch.as_deref(), args.allow_empty)?;
154 return Ok(CommandOutput::CommitDryRun(CommitDryRunResponse {
155 dry_run: true,
156 snapshot_id: prepared.snapshot_id,
157 messages: args.messages,
158 selected_hunks: prepared.selected_hunks,
159 selected_changes: prepared.selected_changes,
160 selected_change_keys: prepared.selected_change_keys,
161 selected_line_ranges: prepared.selected_line_ranges,
162 files: preview.files,
163 patch: preview.patch,
164 diffstat: preview.diffstat,
165 }));
166 }
167
168 if let Some(patch) = prepared.patch.as_deref() {
169 git::apply_patch(repo_root, patch, false)?;
170 }
171
172 if !args.allow_empty && !git::has_staged_changes(repo_root)? {
173 return Err(AppError::new(
174 "nothing_staged",
175 "there are no staged changes to commit".to_string(),
176 ));
177 }
178
179 let commit_sha = git::commit(repo_root, &args.messages, args.allow_empty)?;
180 let next_state = scan::scan_repo(repo_root, cli::Mode::Stage)?;
181
182 Ok(CommandOutput::Commit(CommitResponse {
183 commit: commit_sha,
184 snapshot_id: next_state.snapshot.snapshot_id.clone(),
185 selected_hunks: prepared.selected_hunks,
186 selected_changes: prepared.selected_changes,
187 selected_change_keys: prepared.selected_change_keys,
188 selected_line_ranges: prepared.selected_line_ranges,
189 snapshot: SnapshotOutput::from_snapshot(next_state.snapshot, args.compact),
190 }))
191}
192
193fn prepare_commit_selection(
194 repo_root: &PathBuf,
195 selection: &SelectionInput,
196) -> AppResult<PreparedCommitSelection> {
197 if selection.has_selectors() {
198 let state = validate_snapshot(repo_root, cli::Mode::Stage, selection)?;
199 let resolved = select::resolve_selection(&state, selection)?;
200 let patch = patch::build_patch(&state, &resolved)?;
201 return Ok(PreparedCommitSelection {
202 snapshot_id: state.snapshot.snapshot_id.clone(),
203 patch: Some(patch),
204 selected_hunks: resolved.selected_hunks,
205 selected_changes: resolved.selected_changes,
206 selected_change_keys: resolved.selected_change_keys,
207 selected_line_ranges: resolved.selected_line_ranges,
208 });
209 }
210
211 let state = scan::scan_repo(repo_root, cli::Mode::Stage)?;
212 if let Some(snapshot_id) = selection.snapshot_id.as_ref() {
213 if state.snapshot.snapshot_id != *snapshot_id {
214 return Err(AppError::new(
215 "stale_snapshot",
216 format!(
217 "snapshot '{}' no longer matches the current stage view '{}'",
218 snapshot_id, state.snapshot.snapshot_id
219 ),
220 ));
221 }
222 }
223
224 Ok(PreparedCommitSelection {
225 snapshot_id: state.snapshot.snapshot_id,
226 patch: None,
227 selected_hunks: Vec::new(),
228 selected_changes: Vec::new(),
229 selected_change_keys: Vec::new(),
230 selected_line_ranges: Vec::new(),
231 })
232}
233
234fn validate_snapshot(
235 repo_root: &PathBuf,
236 mode: cli::Mode,
237 selection: &SelectionInput,
238) -> AppResult<ScanState> {
239 let snapshot_id = selection.snapshot_id.as_ref().ok_or_else(|| {
240 AppError::new(
241 "missing_snapshot",
242 "mutating commands require --snapshot or a plan with snapshot_id".to_string(),
243 )
244 })?;
245
246 let state = scan::scan_repo(repo_root, mode)?;
247 if state.snapshot.snapshot_id != *snapshot_id {
248 return Err(AppError::new(
249 "stale_snapshot",
250 format!(
251 "snapshot '{}' no longer matches the current {} view '{}'",
252 snapshot_id,
253 mode.as_str(),
254 state.snapshot.snapshot_id
255 ),
256 ));
257 }
258 Ok(state)
259}
260
261fn load_selection_input(
262 snapshot: Option<String>,
263 plan_path: Option<PathBuf>,
264 hunks: Vec<String>,
265 changes: Vec<String>,
266 change_keys: Vec<String>,
267) -> AppResult<SelectionInput> {
268 let mut input = SelectionInput {
269 snapshot_id: snapshot,
270 hunks: hunks
271 .into_iter()
272 .map(|raw| HunkSelector::parse(&raw))
273 .collect::<AppResult<Vec<_>>>()?,
274 change_ids: changes,
275 change_keys,
276 };
277
278 if let Some(path) = plan_path {
279 let contents = std::fs::read_to_string(&path).map_err(|err| {
280 AppError::new(
281 "plan_read_failed",
282 format!("failed to read {}: {}", path.display(), err),
283 )
284 })?;
285 let plan: SelectionPlan = serde_json::from_str(&contents).map_err(|err| {
286 AppError::new(
287 "plan_parse_failed",
288 format!("failed to parse {}: {}", path.display(), err),
289 )
290 })?;
291
292 if input.snapshot_id.is_none() {
293 input.snapshot_id = Some(plan.snapshot_id);
294 }
295 for selector in plan.selectors {
296 match selector {
297 model::PlanSelector::Hunk { id } => input.hunks.push(HunkSelector::Whole { id }),
298 model::PlanSelector::Change { id } => input.change_ids.push(id),
299 model::PlanSelector::ChangeKey { key } => input.change_keys.push(key),
300 model::PlanSelector::LineRange {
301 hunk_id,
302 side,
303 start,
304 end,
305 } => input
306 .hunks
307 .push(select::HunkSelector::LineRange(select::LineRangeSelector {
308 hunk_id,
309 side,
310 start,
311 end,
312 })),
313 }
314 }
315 }
316
317 Ok(input)
318}
319
320struct PreparedCommitSelection {
321 snapshot_id: String,
322 patch: Option<String>,
323 selected_hunks: Vec<String>,
324 selected_changes: Vec<String>,
325 selected_change_keys: Vec<String>,
326 selected_line_ranges: Vec<String>,
327}
328
329#[derive(Debug)]
330pub enum CommandOutput {
331 Scan(SnapshotOutput),
332 Show(ShowResponse),
333 Resolve(resolve::ResolveResponse),
334 Mutation(MutationResponse),
335 Commit(CommitResponse),
336 CommitDryRun(CommitDryRunResponse),
337}
338
339impl CommandOutput {
340 pub fn to_json_string(&self) -> String {
341 serde_json::to_string_pretty(self).expect("command output should serialize")
342 }
343
344 pub fn to_text(&self) -> String {
345 match self {
346 CommandOutput::Scan(snapshot) => snapshot.to_text(),
347 CommandOutput::Show(show) => show.to_text(),
348 CommandOutput::Resolve(response) => response.to_text(),
349 CommandOutput::Mutation(response) => response.to_text(),
350 CommandOutput::Commit(response) => response.to_text(),
351 CommandOutput::CommitDryRun(response) => response.to_text(),
352 }
353 }
354}
355
356impl Serialize for CommandOutput {
357 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
358 where
359 S: serde::Serializer,
360 {
361 match self {
362 CommandOutput::Scan(snapshot) => snapshot.serialize(serializer),
363 CommandOutput::Show(show) => show.serialize(serializer),
364 CommandOutput::Resolve(response) => response.serialize(serializer),
365 CommandOutput::Mutation(response) => response.serialize(serializer),
366 CommandOutput::Commit(response) => response.serialize(serializer),
367 CommandOutput::CommitDryRun(response) => response.serialize(serializer),
368 }
369 }
370}
371
372#[derive(Debug, Serialize)]
373#[serde(tag = "kind", rename_all = "snake_case")]
374pub enum ShowResponse {
375 Hunk {
376 snapshot_id: String,
377 mode: cli::Mode,
378 path: String,
379 status: model::FileStatus,
380 hunk: HunkView,
381 },
382 Change {
383 snapshot_id: String,
384 mode: cli::Mode,
385 path: String,
386 status: model::FileStatus,
387 change: ChangeView,
388 },
389}
390
391impl ShowResponse {
392 fn to_text(&self) -> String {
393 match self {
394 ShowResponse::Hunk { path, hunk, .. } => {
395 let mut out = format!("{} {}\n", path, hunk.id);
396 out.push_str(&format!("{}\n", hunk.header));
397 for line in &hunk.lines {
398 out.push_str(&format!("{}\n", render_numbered_line(line)));
399 }
400 out.trim_end().to_string()
401 }
402 ShowResponse::Change { path, change, .. } => {
403 let mut out = format!("{} {}\n", path, change.id);
404 out.push_str(&format!(
405 "{} ({}) [{} +{} -{} {}]\n",
406 change.header,
407 change.change_key,
408 change.metadata.kind.as_str(),
409 change.metadata.added_lines,
410 change.metadata.deleted_lines,
411 change.metadata.preview
412 ));
413 for line in &change.lines {
414 out.push_str(&format!("{}\n", render_numbered_line(line)));
415 }
416 out.trim_end().to_string()
417 }
418 }
419 }
420}
421
422fn render_numbered_line(line: &model::DiffLineView) -> String {
423 let old = line
424 .old_lineno
425 .map(|value| value.to_string())
426 .unwrap_or_else(|| "-".to_string());
427 let new = line
428 .new_lineno
429 .map(|value| value.to_string())
430 .unwrap_or_else(|| "-".to_string());
431 format!("{:>4} {:>4} {}", old, new, line.render())
432}
433
434#[derive(Debug, Serialize)]
435pub struct MutationResponse {
436 pub action: &'static str,
437 pub snapshot_id: String,
438 pub mode: cli::Mode,
439 pub selected_hunks: Vec<String>,
440 pub selected_changes: Vec<String>,
441 pub selected_change_keys: Vec<String>,
442 pub selected_line_ranges: Vec<String>,
443 pub snapshot: SnapshotOutput,
444}
445
446impl MutationResponse {
447 fn to_text(&self) -> String {
448 format!(
449 "{}d {} hunks, {} changes, {} change keys, and {} line ranges\nnext snapshot: {}",
450 self.action,
451 self.selected_hunks.len(),
452 self.selected_changes.len(),
453 self.selected_change_keys.len(),
454 self.selected_line_ranges.len(),
455 self.snapshot_id
456 )
457 }
458}
459
460#[derive(Debug, Serialize)]
461pub struct CommitResponse {
462 pub commit: String,
463 pub snapshot_id: String,
464 pub selected_hunks: Vec<String>,
465 pub selected_changes: Vec<String>,
466 pub selected_change_keys: Vec<String>,
467 pub selected_line_ranges: Vec<String>,
468 pub snapshot: SnapshotOutput,
469}
470
471impl CommitResponse {
472 fn to_text(&self) -> String {
473 format!(
474 "committed {} using {} hunks, {} changes, {} change keys, and {} line ranges\nnext snapshot: {}",
475 self.commit,
476 self.selected_hunks.len(),
477 self.selected_changes.len(),
478 self.selected_change_keys.len(),
479 self.selected_line_ranges.len(),
480 self.snapshot_id
481 )
482 }
483}
484
485#[derive(Debug, Serialize)]
486pub struct CommitDryRunResponse {
487 pub dry_run: bool,
488 pub snapshot_id: String,
489 pub messages: Vec<String>,
490 pub selected_hunks: Vec<String>,
491 pub selected_changes: Vec<String>,
492 pub selected_change_keys: Vec<String>,
493 pub selected_line_ranges: Vec<String>,
494 pub files: Vec<String>,
495 pub patch: String,
496 pub diffstat: String,
497}
498
499impl CommitDryRunResponse {
500 fn to_text(&self) -> String {
501 format!(
502 "would commit {} files using {} hunks, {} changes, {} change keys, and {} line ranges\nsnapshot: {}",
503 self.files.len(),
504 self.selected_hunks.len(),
505 self.selected_changes.len(),
506 self.selected_change_keys.len(),
507 self.selected_line_ranges.len(),
508 self.snapshot_id
509 )
510 }
511}