@@ -31,10 +31,13 @@ pub fn parse_description(response: &str) -> Result<FileDescription, ParseDescrip
3131 let lines: Vec < & str > = response. lines ( ) . collect ( ) ;
3232
3333 // Find first line starting with "SHORT:"
34- let short_idx = lines
34+ let short_idx = match lines
3535 . iter ( )
3636 . position ( |l| l. trim_start ( ) . starts_with ( "SHORT:" ) )
37- . ok_or ( ParseDescriptionError :: MissingShort ) ?;
37+ {
38+ Some ( idx) => idx,
39+ None => return parse_description_fallback ( response) ,
40+ } ;
3841
3942 let short = lines[ short_idx]
4043 . trim_start ( )
@@ -78,6 +81,68 @@ pub fn parse_description(response: &str) -> Result<FileDescription, ParseDescrip
7881 Ok ( FileDescription { short, long } )
7982}
8083
84+ /// Fallback parser for LLM responses that lack SHORT:/LONG: markers.
85+ ///
86+ /// Uses the first non-empty line (truncated to 80 chars) as the short
87+ /// description and the remaining text as the long description.
88+ fn parse_description_fallback ( response : & str ) -> Result < FileDescription , ParseDescriptionError > {
89+ let trimmed = response. trim ( ) ;
90+ if trimmed. is_empty ( ) {
91+ return Err ( ParseDescriptionError :: MissingShort ) ;
92+ }
93+
94+ let mut lines = trimmed. lines ( ) ;
95+ let first_line = lines
96+ . next ( )
97+ . map ( |l| l. trim ( ) )
98+ . filter ( |l| !l. is_empty ( ) )
99+ . ok_or ( ParseDescriptionError :: EmptyShort ) ?;
100+
101+ let short: String = first_line. chars ( ) . take ( 80 ) . collect ( ) ;
102+
103+ let long: String = lines. collect :: < Vec < _ > > ( ) . join ( "\n " ) . trim ( ) . to_string ( ) ;
104+
105+ if long. is_empty ( ) {
106+ // Use the short as long too — better than failing
107+ return Ok ( FileDescription {
108+ short : short. clone ( ) ,
109+ long : short,
110+ } ) ;
111+ }
112+
113+ Ok ( FileDescription { short, long } )
114+ }
115+
116+ /// Parse a single line from stdin into a file path, or `None` to skip.
117+ ///
118+ /// Handles:
119+ /// - Plain paths (`src/foo.ts`)
120+ /// - Git porcelain format (`M src/foo.ts`, `?? new_file.ts`)
121+ /// - Renames (`R old.ts -> new.ts` — returns the new path)
122+ /// - Returns `None` for empty lines and `#` comments
123+ pub fn parse_stdin_line ( line : & str ) -> Option < & str > {
124+ let trimmed = line. trim ( ) ;
125+ if trimmed. is_empty ( ) || trimmed. starts_with ( '#' ) {
126+ return None ;
127+ }
128+
129+ // Detect git status --porcelain format: "XY path" where X/Y are status chars
130+ // and position 2 is a space. Status chars: A, M, D, R, C, U, ?, !
131+ let is_porcelain = trimmed. len ( ) > 3
132+ && trimmed. as_bytes ( ) [ 2 ] == b' '
133+ && ( trimmed. as_bytes ( ) [ 0 ] . is_ascii_alphabetic ( )
134+ || trimmed. as_bytes ( ) [ 0 ] == b'?'
135+ || trimmed. as_bytes ( ) [ 0 ] == b'!' ) ;
136+
137+ if is_porcelain {
138+ let rest = & trimmed[ 3 ..] ;
139+ // Handle renames: "old -> new", take the new path
140+ Some ( rest. split ( " -> " ) . last ( ) . unwrap_or ( rest) )
141+ } else {
142+ Some ( trimmed)
143+ }
144+ }
145+
81146#[ cfg( test) ]
82147mod tests {
83148 use super :: * ;
@@ -123,10 +188,11 @@ and starts the server.";
123188 }
124189
125190 #[ test]
126- fn missing_short_returns_error ( ) {
191+ fn missing_short_uses_fallback ( ) {
192+ // Without SHORT: prefix, fallback parser uses first line as short
127193 let input = "LONG: Some long description" ;
128- let err = parse_description ( input) . unwrap_err ( ) ;
129- assert ! ( matches! ( err , ParseDescriptionError :: MissingShort ) ) ;
194+ let desc = parse_description ( input) . unwrap ( ) ;
195+ assert_eq ! ( desc . short , "LONG: Some long description" ) ;
130196 }
131197
132198 #[ test]
@@ -167,16 +233,106 @@ and starts the server.";
167233 }
168234
169235 #[ test]
170- fn case_sensitive_short_required ( ) {
236+ fn case_sensitive_short_falls_back ( ) {
237+ // Without "SHORT:" prefix, fallback parser kicks in
171238 let input = "short: lowercase\n LONG: Something" ;
172- let err = parse_description ( input) . unwrap_err ( ) ;
173- assert ! ( matches!( err, ParseDescriptionError :: MissingShort ) ) ;
239+ let desc = parse_description ( input) . unwrap ( ) ;
240+ assert_eq ! ( desc. short, "short: lowercase" ) ;
241+ assert_eq ! ( desc. long, "LONG: Something" ) ;
174242 }
175243
176244 #[ test]
177- fn case_sensitive_long_required ( ) {
245+ fn case_sensitive_long_falls_back_from_main_to_use_remaining ( ) {
178246 let input = "SHORT: Valid\n long: lowercase" ;
247+ // SHORT: found, but no LONG: — falls back to treating remaining lines as long
179248 let err = parse_description ( input) . unwrap_err ( ) ;
180249 assert ! ( matches!( err, ParseDescriptionError :: MissingLong ) ) ;
181250 }
251+
252+ // -- Fallback parser tests --
253+
254+ #[ test]
255+ fn fallback_freeform_response_uses_first_line_as_short ( ) {
256+ let input = "This file handles authentication.\n It validates tokens and manages sessions." ;
257+ let desc = parse_description ( input) . unwrap ( ) ;
258+ assert_eq ! ( desc. short, "This file handles authentication." ) ;
259+ assert_eq ! ( desc. long, "It validates tokens and manages sessions." ) ;
260+ }
261+
262+ #[ test]
263+ fn fallback_single_line_uses_same_for_both ( ) {
264+ let input = "A utility module for string processing." ;
265+ let desc = parse_description ( input) . unwrap ( ) ;
266+ assert_eq ! ( desc. short, "A utility module for string processing." ) ;
267+ assert_eq ! ( desc. long, "A utility module for string processing." ) ;
268+ }
269+
270+ #[ test]
271+ fn fallback_truncates_short_to_80_chars ( ) {
272+ let input = "a" . repeat ( 120 ) + "\n Some long description here." ;
273+ let desc = parse_description ( & input) . unwrap ( ) ;
274+ assert_eq ! ( desc. short. len( ) , 80 ) ;
275+ assert_eq ! ( desc. long, "Some long description here." ) ;
276+ }
277+
278+ #[ test]
279+ fn fallback_empty_response_fails ( ) {
280+ let input = "" ;
281+ let err = parse_description ( input) . unwrap_err ( ) ;
282+ assert ! ( matches!( err, ParseDescriptionError :: MissingShort ) ) ;
283+ }
284+
285+ // -- parse_stdin_line tests --
286+
287+ #[ test]
288+ fn stdin_line_plain_path ( ) {
289+ assert_eq ! ( parse_stdin_line( "src/foo.ts" ) , Some ( "src/foo.ts" ) ) ;
290+ }
291+
292+ #[ test]
293+ fn stdin_line_porcelain_modified ( ) {
294+ assert_eq ! ( parse_stdin_line( "M src/foo.ts" ) , Some ( "src/foo.ts" ) ) ;
295+ }
296+
297+ #[ test]
298+ fn stdin_line_porcelain_added ( ) {
299+ assert_eq ! ( parse_stdin_line( "A src/new.ts" ) , Some ( "src/new.ts" ) ) ;
300+ }
301+
302+ #[ test]
303+ fn stdin_line_porcelain_untracked ( ) {
304+ assert_eq ! ( parse_stdin_line( "?? src/new.ts" ) , Some ( "src/new.ts" ) ) ;
305+ }
306+
307+ #[ test]
308+ fn stdin_line_porcelain_rename ( ) {
309+ assert_eq ! ( parse_stdin_line( "R old.ts -> new.ts" ) , Some ( "new.ts" ) ) ;
310+ }
311+
312+ #[ test]
313+ fn stdin_line_empty ( ) {
314+ assert_eq ! ( parse_stdin_line( "" ) , None ) ;
315+ }
316+
317+ #[ test]
318+ fn stdin_line_whitespace_only ( ) {
319+ assert_eq ! ( parse_stdin_line( " " ) , None ) ;
320+ }
321+
322+ #[ test]
323+ fn stdin_line_comment ( ) {
324+ assert_eq ! ( parse_stdin_line( "# this is a comment" ) , None ) ;
325+ }
326+
327+ #[ test]
328+ fn stdin_line_short_path_not_porcelain ( ) {
329+ // "ab" is only 2 chars — too short to be porcelain format
330+ assert_eq ! ( parse_stdin_line( "ab" ) , Some ( "ab" ) ) ;
331+ }
332+
333+ #[ test]
334+ fn stdin_line_path_starting_with_question_mark_not_porcelain ( ) {
335+ // "?readme.txt" has no space at position 2, so it's a plain path
336+ assert_eq ! ( parse_stdin_line( "?readme.txt" ) , Some ( "?readme.txt" ) ) ;
337+ }
182338}
0 commit comments