Skip to main content

git_hunk/
lib.rs

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}