Skip to main content

binocular/output/
render.rs

1use super::SelectionOutput;
2use crate::cli::args::OutputFormat;
3use crate::search::sources::git::HISTORY_PATH_SEPARATOR;
4use crate::search::types::SearchItem;
5use std::path::Path;
6
7impl SelectionOutput {
8    pub fn render(&self, format: OutputFormat) -> String {
9        match format {
10            OutputFormat::Plain => self.render_plain(),
11            OutputFormat::Jsonl => self.render_jsonl(),
12        }
13    }
14
15    fn render_plain(&self) -> String {
16        match self {
17            Self::Item { item, column } => format_item_output(item, *column, true),
18            Self::PreviewLocation { path, row, column } => format!("{path}:{row}:{column}"),
19        }
20    }
21
22    fn render_jsonl(&self) -> String {
23        match self {
24            Self::Item {
25                item: SearchItem::Stdin(text),
26                ..
27            } => serde_json::json!({
28                "kind": "stdin",
29                "text": text,
30            })
31            .to_string(),
32            Self::Item {
33                item: SearchItem::Path(path),
34                ..
35            } => serde_json::json!({
36                "kind": "path",
37                "path": canonicalize_or_clone(path),
38            })
39            .to_string(),
40            Self::Item {
41                item: SearchItem::Grep { path, line, .. },
42                column,
43            } => {
44                let mut value = serde_json::json!({
45                    "kind": "grep",
46                    "path": canonicalize_or_clone(path),
47                    "line": line,
48                });
49                if let Some(column) = column {
50                    value["column"] = serde_json::json!(column);
51                }
52                value.to_string()
53            }
54            Self::Item {
55                item:
56                    SearchItem::GitHistory {
57                        commit, path, line, ..
58                    },
59                column,
60            } => {
61                let mut value = serde_json::json!({
62                    "kind": "git_history",
63                    "commit": commit,
64                    "path": path,
65                    "line": line,
66                });
67                if let Some(column) = column {
68                    value["column"] = serde_json::json!(column);
69                }
70                value.to_string()
71            }
72            Self::Item {
73                item: SearchItem::GitBranch { branch, .. },
74                ..
75            } => serde_json::json!({
76                "kind": "git_branch",
77                "branch": branch,
78            })
79            .to_string(),
80            Self::Item {
81                item: SearchItem::GitCommit { commit, .. },
82                ..
83            } => serde_json::json!({
84                "kind": "git_commit",
85                "commit": commit,
86            })
87            .to_string(),
88            Self::Item {
89                item: SearchItem::Message(text),
90                ..
91            } => serde_json::json!({
92                "kind": "message",
93                "text": text,
94            })
95            .to_string(),
96            Self::PreviewLocation { path, row, column } => serde_json::json!({
97                "kind": "preview_location",
98                "path": canonicalize_or_clone(path),
99                "line": row,
100                "column": column,
101            })
102            .to_string(),
103        }
104    }
105}
106
107pub fn format_item_output(item: &SearchItem, column: Option<usize>, canonicalize: bool) -> String {
108    match item {
109        SearchItem::Stdin(text) | SearchItem::Message(text) => text.clone(),
110        SearchItem::Path(path) => {
111            if canonicalize {
112                Path::new(path)
113                    .canonicalize()
114                    .map(|p| p.display().to_string())
115                    .unwrap_or_else(|_| path.clone())
116            } else {
117                path.clone()
118            }
119        }
120        SearchItem::Grep { path, line, .. } => {
121            let abs_path = if canonicalize {
122                Path::new(path)
123                    .canonicalize()
124                    .map(|p| p.display().to_string())
125                    .unwrap_or_else(|_| path.clone())
126            } else {
127                path.clone()
128            };
129
130            if let Some(col) = column {
131                format!("{}:{}:{}", abs_path, line, col)
132            } else {
133                format!("{}:{}", abs_path, line)
134            }
135        }
136        SearchItem::GitHistory {
137            commit, path, line, ..
138        } => {
139            let display_path = path.replace(HISTORY_PATH_SEPARATOR, "/");
140            format!("{}:{}:{}", commit, display_path, line)
141        }
142        SearchItem::GitBranch { branch, .. } => branch.clone(),
143        SearchItem::GitCommit { commit, .. } => commit.clone(),
144    }
145}
146
147pub fn render_selection_outputs(
148    outputs: &[SelectionOutput],
149    format: OutputFormat,
150) -> Option<String> {
151    if outputs.is_empty() {
152        return None;
153    }
154
155    Some(
156        outputs
157            .iter()
158            .map(|output| output.render(format))
159            .collect::<Vec<_>>()
160            .join("\n"),
161    )
162}
163
164fn canonicalize_or_clone(path: &str) -> String {
165    Path::new(path)
166        .canonicalize()
167        .map(|p| p.display().to_string())
168        .unwrap_or_else(|_| path.to_string())
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn format_item_output_preserves_windows_style_paths() {
177        let item = SearchItem::path(r"C:\work\project:file.rs");
178        assert_eq!(
179            format_item_output(&item, None, false),
180            r"C:\work\project:file.rs"
181        );
182    }
183
184    #[test]
185    fn format_item_output_handles_grep_column_edge_cases() {
186        let item = SearchItem::grep(r"C:\work\main.rs", 42, "let value = 1;");
187        assert_eq!(
188            format_item_output(&item, Some(7), false),
189            r"C:\work\main.rs:42:7"
190        );
191        assert_eq!(
192            format_item_output(&item, None, false),
193            r"C:\work\main.rs:42"
194        );
195    }
196
197    #[test]
198    fn format_item_output_renders_git_history_item() {
199        let item = SearchItem::history_line("abc123", "Architecture.md", 42, "text");
200        assert_eq!(
201            format_item_output(&item, None, false),
202            "abc123:Architecture.md:42"
203        );
204    }
205
206    #[test]
207    fn jsonl_path_output_is_machine_readable() {
208        let rendered = SelectionOutput::Item {
209            item: SearchItem::path(r"C:\work\project:file.rs"),
210            column: None,
211        }
212        .render(OutputFormat::Jsonl);
213
214        assert_eq!(
215            rendered,
216            serde_json::json!({
217                "kind": "path",
218                "path": r"C:\work\project:file.rs",
219            })
220            .to_string()
221        );
222    }
223
224    #[test]
225    fn jsonl_grep_output_keeps_optional_column() {
226        let rendered = SelectionOutput::Item {
227            item: SearchItem::grep(r"C:\work\main.rs", 42, "let value = 1;"),
228            column: Some(7),
229        }
230        .render(OutputFormat::Jsonl);
231
232        assert_eq!(
233            rendered,
234            serde_json::json!({
235                "kind": "grep",
236                "path": r"C:\work\main.rs",
237                "line": 42,
238                "column": 7,
239            })
240            .to_string()
241        );
242    }
243
244    #[test]
245    fn jsonl_preview_output_uses_line_and_column_fields() {
246        let rendered = SelectionOutput::PreviewLocation {
247            path: r"C:\work\main.rs".to_string(),
248            row: 24,
249            column: 4,
250        }
251        .render(OutputFormat::Jsonl);
252
253        assert_eq!(
254            rendered,
255            serde_json::json!({
256                "kind": "preview_location",
257                "path": r"C:\work\main.rs",
258                "line": 24,
259                "column": 4,
260            })
261            .to_string()
262        );
263    }
264
265    #[test]
266    fn render_selection_outputs_joins_multiple_records() {
267        let rendered = render_selection_outputs(
268            &[
269                SelectionOutput::Item {
270                    item: SearchItem::path("first.txt"),
271                    column: None,
272                },
273                SelectionOutput::Item {
274                    item: SearchItem::path("second.txt"),
275                    column: None,
276                },
277            ],
278            OutputFormat::Jsonl,
279        )
280        .expect("joined output");
281
282        let lines: Vec<_> = rendered.lines().collect();
283        assert_eq!(lines.len(), 2);
284        assert!(lines[0].contains("\"kind\":\"path\""));
285        assert!(lines[1].contains("\"kind\":\"path\""));
286    }
287}