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}