1pub mod cli;
2mod diff;
3mod error;
4mod git;
5mod model;
6mod patch;
7mod scan;
8mod select;
9
10use std::path::PathBuf;
11
12use cli::{Cli, Command, CommitArgs, MutateArgs, ScanArgs, ShowArgs};
13use error::{AppError, AppResult};
14use model::{ChangeView, HunkView, ScanState, SelectionPlan, SnapshotView};
15use select::{HunkSelector, SelectionInput};
16use serde::Serialize;
17
18pub use error::AppError as Error;
19
20pub fn run(cli: Cli) -> AppResult<CommandOutput> {
21 let repo_root = git::repo_root(&std::env::current_dir().map_err(AppError::io)?)?;
22
23 match cli.command {
24 Command::Scan(args) => scan_command(&repo_root, args),
25 Command::Show(args) => show_command(&repo_root, args),
26 Command::Stage(args) => mutate_command(&repo_root, args, false),
27 Command::Unstage(args) => mutate_command(&repo_root, args, true),
28 Command::Commit(args) => commit_command(&repo_root, args),
29 }
30}
31
32fn scan_command(repo_root: &PathBuf, args: ScanArgs) -> AppResult<CommandOutput> {
33 let state = scan::scan_repo(repo_root, args.mode)?;
34 Ok(CommandOutput::Scan(state.snapshot.clone()))
35}
36
37fn show_command(repo_root: &PathBuf, args: ShowArgs) -> AppResult<CommandOutput> {
38 let state = scan::scan_repo(repo_root, args.mode)?;
39
40 if let Some((file, hunk)) = state.find_hunk(&args.id) {
41 return Ok(CommandOutput::Show(ShowResponse::Hunk {
42 snapshot_id: state.snapshot.snapshot_id.clone(),
43 mode: state.snapshot.mode,
44 path: file.path.clone(),
45 status: file.status,
46 hunk: hunk.clone(),
47 }));
48 }
49
50 if let Some((file, change)) = state.find_change(&args.id) {
51 return Ok(CommandOutput::Show(ShowResponse::Change {
52 snapshot_id: state.snapshot.snapshot_id.clone(),
53 mode: state.snapshot.mode,
54 path: file.path.clone(),
55 status: file.status,
56 change: change.clone(),
57 }));
58 }
59
60 Err(AppError::new(
61 "unknown_id",
62 format!("no hunk or change found for id '{}'", args.id),
63 ))
64}
65
66fn mutate_command(
67 repo_root: &PathBuf,
68 args: MutateArgs,
69 reverse: bool,
70) -> AppResult<CommandOutput> {
71 let mode = if reverse {
72 cli::Mode::Unstage
73 } else {
74 cli::Mode::Stage
75 };
76 let selection = load_selection_input(args.snapshot, args.plan, args.hunks, args.changes)?;
77 let state = validate_snapshot(repo_root, mode, &selection)?;
78 let resolved = select::resolve_selection(&state, &selection)?;
79 let patch = patch::build_patch(&state, &resolved)?;
80
81 git::apply_patch(repo_root, &patch, reverse)?;
82
83 let next_state = scan::scan_repo(repo_root, mode)?;
84 Ok(CommandOutput::Mutation(MutationResponse {
85 action: if reverse { "unstage" } else { "stage" },
86 snapshot_id: next_state.snapshot.snapshot_id.clone(),
87 mode,
88 selected_hunks: resolved.selected_hunks,
89 selected_changes: resolved.selected_changes,
90 selected_line_ranges: resolved.selected_line_ranges,
91 snapshot: next_state.snapshot,
92 }))
93}
94
95fn commit_command(repo_root: &PathBuf, args: CommitArgs) -> AppResult<CommandOutput> {
96 if args.messages.is_empty() {
97 return Err(AppError::new(
98 "missing_message",
99 "commit requires at least one message".to_string(),
100 ));
101 }
102
103 let selection = load_selection_input(args.snapshot, args.plan, args.hunks, args.changes)?;
104 let mut selected_hunks = Vec::new();
105 let mut selected_changes = Vec::new();
106 let mut selected_line_ranges = Vec::new();
107
108 if selection.has_selectors() {
109 let state = validate_snapshot(repo_root, cli::Mode::Stage, &selection)?;
110 let resolved = select::resolve_selection(&state, &selection)?;
111 let patch = patch::build_patch(&state, &resolved)?;
112 git::apply_patch(repo_root, &patch, false)?;
113 selected_hunks = resolved.selected_hunks;
114 selected_changes = resolved.selected_changes;
115 selected_line_ranges = resolved.selected_line_ranges;
116 } else if let Some(snapshot_id) = selection.snapshot_id.as_ref() {
117 let state = scan::scan_repo(repo_root, cli::Mode::Stage)?;
118 if state.snapshot.snapshot_id != *snapshot_id {
119 return Err(AppError::new(
120 "stale_snapshot",
121 format!(
122 "snapshot '{}' no longer matches the current stage view '{}'",
123 snapshot_id, state.snapshot.snapshot_id
124 ),
125 ));
126 }
127 }
128
129 if !args.allow_empty && !git::has_staged_changes(repo_root)? {
130 return Err(AppError::new(
131 "nothing_staged",
132 "there are no staged changes to commit".to_string(),
133 ));
134 }
135
136 let commit_sha = git::commit(repo_root, &args.messages, args.allow_empty)?;
137 let next_state = scan::scan_repo(repo_root, cli::Mode::Stage)?;
138
139 Ok(CommandOutput::Commit(CommitResponse {
140 commit: commit_sha,
141 snapshot_id: next_state.snapshot.snapshot_id.clone(),
142 selected_hunks,
143 selected_changes,
144 selected_line_ranges,
145 snapshot: next_state.snapshot,
146 }))
147}
148
149fn validate_snapshot(
150 repo_root: &PathBuf,
151 mode: cli::Mode,
152 selection: &SelectionInput,
153) -> AppResult<ScanState> {
154 let snapshot_id = selection.snapshot_id.as_ref().ok_or_else(|| {
155 AppError::new(
156 "missing_snapshot",
157 "mutating commands require --snapshot or a plan with snapshot_id".to_string(),
158 )
159 })?;
160
161 let state = scan::scan_repo(repo_root, mode)?;
162 if state.snapshot.snapshot_id != *snapshot_id {
163 return Err(AppError::new(
164 "stale_snapshot",
165 format!(
166 "snapshot '{}' no longer matches the current {} view '{}'",
167 snapshot_id,
168 mode.as_str(),
169 state.snapshot.snapshot_id
170 ),
171 ));
172 }
173 Ok(state)
174}
175
176fn load_selection_input(
177 snapshot: Option<String>,
178 plan_path: Option<PathBuf>,
179 hunks: Vec<String>,
180 changes: Vec<String>,
181) -> AppResult<SelectionInput> {
182 let mut input = SelectionInput {
183 snapshot_id: snapshot,
184 hunks: hunks
185 .into_iter()
186 .map(|raw| HunkSelector::parse(&raw))
187 .collect::<AppResult<Vec<_>>>()?,
188 change_ids: changes,
189 };
190
191 if let Some(path) = plan_path {
192 let contents = std::fs::read_to_string(&path).map_err(|err| {
193 AppError::new(
194 "plan_read_failed",
195 format!("failed to read {}: {}", path.display(), err),
196 )
197 })?;
198 let plan: SelectionPlan = serde_json::from_str(&contents).map_err(|err| {
199 AppError::new(
200 "plan_parse_failed",
201 format!("failed to parse {}: {}", path.display(), err),
202 )
203 })?;
204
205 if input.snapshot_id.is_none() {
206 input.snapshot_id = Some(plan.snapshot_id);
207 }
208 for selector in plan.selectors {
209 match selector {
210 model::PlanSelector::Hunk { id } => input.hunks.push(HunkSelector::Whole { id }),
211 model::PlanSelector::Change { id } => input.change_ids.push(id),
212 model::PlanSelector::LineRange {
213 hunk_id,
214 side,
215 start,
216 end,
217 } => input
218 .hunks
219 .push(HunkSelector::LineRange(select::LineRangeSelector {
220 hunk_id,
221 side,
222 start,
223 end,
224 })),
225 }
226 }
227 }
228
229 Ok(input)
230}
231
232#[derive(Debug)]
233pub enum CommandOutput {
234 Scan(SnapshotView),
235 Show(ShowResponse),
236 Mutation(MutationResponse),
237 Commit(CommitResponse),
238}
239
240impl CommandOutput {
241 pub fn to_json_string(&self) -> String {
242 serde_json::to_string_pretty(self).expect("command output should serialize")
243 }
244
245 pub fn to_text(&self) -> String {
246 match self {
247 CommandOutput::Scan(snapshot) => snapshot.to_text(),
248 CommandOutput::Show(show) => show.to_text(),
249 CommandOutput::Mutation(response) => response.to_text(),
250 CommandOutput::Commit(response) => response.to_text(),
251 }
252 }
253}
254
255impl Serialize for CommandOutput {
256 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
257 where
258 S: serde::Serializer,
259 {
260 match self {
261 CommandOutput::Scan(snapshot) => snapshot.serialize(serializer),
262 CommandOutput::Show(show) => show.serialize(serializer),
263 CommandOutput::Mutation(response) => response.serialize(serializer),
264 CommandOutput::Commit(response) => response.serialize(serializer),
265 }
266 }
267}
268
269#[derive(Debug, Serialize)]
270#[serde(tag = "kind", rename_all = "snake_case")]
271pub enum ShowResponse {
272 Hunk {
273 snapshot_id: String,
274 mode: cli::Mode,
275 path: String,
276 status: model::FileStatus,
277 hunk: HunkView,
278 },
279 Change {
280 snapshot_id: String,
281 mode: cli::Mode,
282 path: String,
283 status: model::FileStatus,
284 change: ChangeView,
285 },
286}
287
288impl ShowResponse {
289 fn to_text(&self) -> String {
290 match self {
291 ShowResponse::Hunk { path, hunk, .. } => {
292 let mut out = format!("{} {}\n", path, hunk.id);
293 out.push_str(&format!("{}\n", hunk.header));
294 for line in &hunk.lines {
295 out.push_str(&format!("{}\n", render_numbered_line(line)));
296 }
297 out.trim_end().to_string()
298 }
299 ShowResponse::Change { path, change, .. } => {
300 let mut out = format!("{} {}\n", path, change.id);
301 out.push_str(&format!("{}\n", change.header));
302 for line in &change.lines {
303 out.push_str(&format!("{}\n", render_numbered_line(line)));
304 }
305 out.trim_end().to_string()
306 }
307 }
308 }
309}
310
311fn render_numbered_line(line: &model::DiffLineView) -> String {
312 let old = line
313 .old_lineno
314 .map(|value| value.to_string())
315 .unwrap_or_else(|| "-".to_string());
316 let new = line
317 .new_lineno
318 .map(|value| value.to_string())
319 .unwrap_or_else(|| "-".to_string());
320 format!("{:>4} {:>4} {}", old, new, line.render())
321}
322
323#[derive(Debug, Serialize)]
324pub struct MutationResponse {
325 pub action: &'static str,
326 pub snapshot_id: String,
327 pub mode: cli::Mode,
328 pub selected_hunks: Vec<String>,
329 pub selected_changes: Vec<String>,
330 pub selected_line_ranges: Vec<String>,
331 pub snapshot: SnapshotView,
332}
333
334impl MutationResponse {
335 fn to_text(&self) -> String {
336 format!(
337 "{}d {} hunks, {} changes, and {} line ranges\nnext snapshot: {}",
338 self.action,
339 self.selected_hunks.len(),
340 self.selected_changes.len(),
341 self.selected_line_ranges.len(),
342 self.snapshot_id
343 )
344 }
345}
346
347#[derive(Debug, Serialize)]
348pub struct CommitResponse {
349 pub commit: String,
350 pub snapshot_id: String,
351 pub selected_hunks: Vec<String>,
352 pub selected_changes: Vec<String>,
353 pub selected_line_ranges: Vec<String>,
354 pub snapshot: SnapshotView,
355}
356
357impl CommitResponse {
358 fn to_text(&self) -> String {
359 format!(
360 "committed {} using {} hunks, {} changes, and {} line ranges\nnext snapshot: {}",
361 self.commit,
362 self.selected_hunks.len(),
363 self.selected_changes.len(),
364 self.selected_line_ranges.len(),
365 self.snapshot_id
366 )
367 }
368}