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