File size: 13,788 Bytes
767452b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
use forge_domain::{Todo, TodoStatus};

/// Controls the styling applied to a rendered todo line.
enum TodoLineStyle {
    /// Bold styling used for new or changed todos.
    Bold,
    /// Dim styling used for unchanged todos.
    Dim,
}

/// Renders one todo line with icon and ANSI styling.
///
/// # Arguments
///
/// * `todo` - Todo item to render.
/// * `line_style` - Emphasis style for the line.
fn format_todo_line(todo: &Todo, line_style: TodoLineStyle) -> String {
    use console::style;

    let checkbox = match todo.status {
        TodoStatus::Completed => "σ°„΅",
        TodoStatus::InProgress => "σ°„—",
        TodoStatus::Pending => "σ°„±",
        TodoStatus::Cancelled => "ο…‡",
    };

    let content = match todo.status {
        TodoStatus::Completed | TodoStatus::Cancelled => {
            style(todo.content.as_str()).strikethrough().to_string()
        }
        _ => todo.content.clone(),
    };

    let line = format!("  {checkbox} {content}");
    let styled = match (&todo.status, line_style) {
        (TodoStatus::Pending, TodoLineStyle::Bold) => style(line).white().bold().to_string(),
        (TodoStatus::Pending, TodoLineStyle::Dim) => style(line).white().dim().to_string(),
        (TodoStatus::Cancelled, TodoLineStyle::Bold) => style(line).red().bold().to_string(),
        (TodoStatus::Cancelled, TodoLineStyle::Dim) => style(line).red().dim().to_string(),
        (TodoStatus::InProgress, TodoLineStyle::Bold) => style(line).cyan().bold().to_string(),
        (TodoStatus::InProgress, TodoLineStyle::Dim) => style(line).cyan().dim().to_string(),
        (TodoStatus::Completed, TodoLineStyle::Bold) => style(line).green().bold().to_string(),
        (TodoStatus::Completed, TodoLineStyle::Dim) => style(line).green().dim().to_string(),
    };

    format!("{styled}\n")
}

/// Formats a todo diff showing all todos in `after` plus removed todos from
/// `before`.
///
/// # Arguments
///
/// * `before` - Previous todo list state.
/// * `after` - New todo list state.
pub(crate) fn format_todos_diff(before: &[Todo], after: &[Todo]) -> String {
    use console::style;

    let before_map: std::collections::HashMap<&str, &Todo> =
        before.iter().map(|todo| (todo.id.as_str(), todo)).collect();
    let after_map: std::collections::HashMap<&str, &Todo> =
        after.iter().map(|todo| (todo.id.as_str(), todo)).collect();

    let mut result = "\n".to_string();

    // Walk `before` in insertion order: emit the current version of surviving
    // items, or the removed rendering for items that were dropped.
    for before_todo in before {
        if let Some(after_todo) = after_map.get(before_todo.id.as_str()).copied() {
            // Item still exists β€” render with bold/dim based on whether it changed.
            let is_changed = before_todo.status != after_todo.status
                || before_todo.content != after_todo.content;
            let line_style = if is_changed {
                TodoLineStyle::Bold
            } else {
                TodoLineStyle::Dim
            };
            result.push_str(&format_todo_line(after_todo, line_style));
        } else {
            // Item was removed β€” render with status-aware styling.
            let content = style(before_todo.content.as_str())
                .strikethrough()
                .to_string();
            if before_todo.status == TodoStatus::Completed {
                // Removed completed: dimmed white checkmark (historical done)
                result.push_str(&format!(
                    "  {}\n",
                    style(format!("σ°„΅ {content}")).white().dim()
                ));
            } else {
                // Removed non-completed: use the correct status icon in red
                let checkbox = match before_todo.status {
                    TodoStatus::InProgress => "σ°„—",
                    TodoStatus::Pending => "σ°„±",
                    TodoStatus::Cancelled => "σ°…™",
                    TodoStatus::Completed => "σ°„΅",
                };
                result.push_str(&format!(
                    "  {}\n",
                    style(format!("{checkbox} {content}")).red()
                ));
            }
        }
    }

    // Append newly-added items (present in `after` but not in `before`) in
    // their original insertion order.
    for todo in after {
        if !before_map.contains_key(todo.id.as_str()) {
            result.push_str(&format_todo_line(todo, TodoLineStyle::Bold));
        }
    }

    result
}

/// Formats todos as ANSI-styled checklist lines.
///
/// # Arguments
///
/// * `todos` - Todo list to format.
pub(crate) fn format_todos(todos: &[Todo]) -> String {
    if todos.is_empty() {
        return String::new();
    }

    let mut result = "\n".to_string();

    for todo in todos {
        result.push_str(&format_todo_line(todo, TodoLineStyle::Dim));
    }

    result
}

#[cfg(test)]
mod tests {
    use std::sync::Mutex;

    use console::{
        colors_enabled, colors_enabled_stderr, set_colors_enabled, set_colors_enabled_stderr,
        strip_ansi_codes,
    };
    use forge_domain::{ChatResponseContent, Environment, Todo, TodoStatus};
    use insta::assert_snapshot;
    use pretty_assertions::assert_eq;

    use crate::fmt::content::FormatContent;
    use crate::operation::ToolOperation;

    static ANSI_STYLE_LOCK: Mutex<()> = Mutex::new(());

    struct ColorStateGuard {
        stdout: bool,
        stderr: bool,
    }

    impl ColorStateGuard {
        fn force_enabled() -> Self {
            let stdout = colors_enabled();
            let stderr = colors_enabled_stderr();
            set_colors_enabled(true);
            set_colors_enabled_stderr(true);
            Self { stdout, stderr }
        }
    }

    impl Drop for ColorStateGuard {
        fn drop(&mut self) {
            set_colors_enabled(self.stdout);
            set_colors_enabled_stderr(self.stderr);
        }
    }

    fn fixture_environment() -> Environment {
        use fake::{Fake, Faker};
        Faker.fake()
    }

    fn fixture_todo(content: &str, id: &str, status: TodoStatus) -> Todo {
        Todo::new(content).id(id).status(status)
    }

    fn fixture_todo_write_output_raw(before: Vec<Todo>, after: Vec<Todo>) -> String {
        let _lock = ANSI_STYLE_LOCK
            .lock()
            .expect("ANSI style lock should not be poisoned");
        let _colors = ColorStateGuard::force_enabled();
        let setup = ToolOperation::TodoWrite { before, after };
        let actual = setup.to_content(&fixture_environment());

        if let Some(ChatResponseContent::ToolOutput(output)) = actual {
            output
        } else {
            panic!("Expected ToolOutput content")
        }
    }

    #[test]
    fn test_todo_write_removed_in_progress_renders_with_in_progress_icon_in_raw_snapshot() {
        // before: Write migrations is in_progress
        // after:  empty (it was cancelled/removed)
        let setup = (
            vec![fixture_todo(
                "Write migrations",
                "1",
                TodoStatus::InProgress,
            )],
            Vec::new(),
        );

        // Verify icon (strip color) β€” must be σ°„—, NOT σ°„±
        let plain = fixture_todo_write_output(setup.0.clone(), setup.1.clone());
        let expected_plain = "\n  σ°„— Write migrations\n";
        assert_eq!(plain, expected_plain);

        let raw = fixture_todo_write_output_raw(setup.0, setup.1);
        assert_snapshot!(raw);
    }

    #[test]
    fn test_todo_write_removed_pending_renders_with_pending_icon_in_raw_snapshot() {
        let setup = (
            vec![fixture_todo("Pending task", "1", TodoStatus::Pending)],
            Vec::new(),
        );

        let plain = fixture_todo_write_output(setup.0.clone(), setup.1.clone());
        let expected_plain = "\n  σ°„± Pending task\n";
        assert_eq!(plain, expected_plain);

        let raw = fixture_todo_write_output_raw(setup.0, setup.1);
        assert_snapshot!(raw);
    }

    fn fixture_todo_write_output(before: Vec<Todo>, after: Vec<Todo>) -> String {
        let setup = ToolOperation::TodoWrite { before, after };
        let actual = setup.to_content(&fixture_environment());

        if let Some(ChatResponseContent::ToolOutput(output)) = actual {
            strip_ansi_codes(output.as_str()).to_string()
        } else {
            panic!("Expected ToolOutput content")
        }
    }

    #[test]
    fn test_todo_write_mixed_changes_snapshot() {
        let setup = (
            vec![
                fixture_todo("Task 1", "1", TodoStatus::Pending),
                fixture_todo("Task 2", "2", TodoStatus::InProgress),
            ],
            vec![
                fixture_todo("Task 1", "1", TodoStatus::Completed),
                fixture_todo("Task 3", "3", TodoStatus::Pending),
            ],
        );

        let actual = fixture_todo_write_output(setup.0, setup.1);
        assert_snapshot!(actual);
    }

    #[test]
    fn test_todo_write_removed_completed_todos_render_as_dimmed_done() {
        let setup = (
            vec![fixture_todo("Done", "1", TodoStatus::Completed)],
            Vec::new(),
        );

        let actual = fixture_todo_write_output(setup.0, setup.1);
        let expected = "\n  σ°„΅ Done\n";

        assert_eq!(actual, expected);
    }

    #[test]
    fn test_format_todos_preserves_insertion_order() {
        // Items are given in insertion order: Second was added first, First second.
        // Output must reflect that insertion order, not alphabetical or id-sorted.
        let setup = vec![
            fixture_todo("Second", "2", TodoStatus::Pending),
            fixture_todo("First", "1", TodoStatus::Pending),
        ];

        let actual = strip_ansi_codes(super::format_todos(&setup).as_str()).to_string();
        let expected = "\n  σ°„± Second\n  σ°„± First\n";

        assert_eq!(actual, expected);
    }

    #[test]
    fn test_todo_write_dump_flow_in_same_order() {
        let step_1 = vec![
            fixture_todo(
                "Generate JSONL input file with all 59 cases",
                "1",
                TodoStatus::InProgress,
            ),
            fixture_todo(
                "Create JSON schema file for structured output",
                "2",
                TodoStatus::Pending,
            ),
            fixture_todo("Create system prompt template", "3", TodoStatus::Pending),
            fixture_todo("Create user prompt template", "4", TodoStatus::Pending),
            fixture_todo("Test with 2-3 cases first", "5", TodoStatus::Pending),
            fixture_todo("Run for all cases", "6", TodoStatus::Pending),
        ];
        let step_2 = vec![
            fixture_todo(
                "Generate JSONL input file with all 59 cases",
                "1",
                TodoStatus::Completed,
            ),
            fixture_todo(
                "Create JSON schema file for structured output",
                "2",
                TodoStatus::InProgress,
            ),
            fixture_todo("Create system prompt template", "3", TodoStatus::Pending),
            fixture_todo("Create user prompt template", "4", TodoStatus::Pending),
            fixture_todo("Test with 2-3 cases first", "5", TodoStatus::Pending),
            fixture_todo("Run for all cases", "6", TodoStatus::Pending),
        ];
        let step_3 = vec![
            fixture_todo(
                "Create JSON schema file for structured output",
                "2",
                TodoStatus::Completed,
            ),
            fixture_todo("Create system prompt template", "3", TodoStatus::InProgress),
            fixture_todo("Create user prompt template", "4", TodoStatus::Pending),
            fixture_todo("Test with 2-3 cases first", "5", TodoStatus::Pending),
            fixture_todo("Run for all cases", "6", TodoStatus::Pending),
        ];
        let step_4 = vec![
            fixture_todo("Create system prompt template", "3", TodoStatus::Completed),
            fixture_todo("Create user prompt template", "4", TodoStatus::Completed),
            fixture_todo("Test with 2-3 cases first", "5", TodoStatus::InProgress),
            fixture_todo("Run for all cases", "6", TodoStatus::Pending),
        ];

        let actual_1 = fixture_todo_write_output(Vec::new(), step_1.clone());
        let expected_1 = "\n  σ°„— Generate JSONL input file with all 59 cases\n  σ°„± Create JSON schema file for structured output\n  σ°„± Create system prompt template\n  σ°„± Create user prompt template\n  σ°„± Test with 2-3 cases first\n  σ°„± Run for all cases\n";
        assert_eq!(actual_1, expected_1);

        let actual_2 = fixture_todo_write_output(step_1.clone(), step_2.clone());
        let expected_2 = "\n  σ°„΅ Generate JSONL input file with all 59 cases\n  σ°„— Create JSON schema file for structured output\n  σ°„± Create system prompt template\n  σ°„± Create user prompt template\n  σ°„± Test with 2-3 cases first\n  σ°„± Run for all cases\n";
        assert_eq!(actual_2, expected_2);

        let actual_3 = fixture_todo_write_output(step_2.clone(), step_3.clone());
        let expected_3 = "\n  σ°„΅ Generate JSONL input file with all 59 cases\n  σ°„΅ Create JSON schema file for structured output\n  σ°„— Create system prompt template\n  σ°„± Create user prompt template\n  σ°„± Test with 2-3 cases first\n  σ°„± Run for all cases\n";
        assert_eq!(actual_3, expected_3);

        let actual_4 = fixture_todo_write_output(step_3, step_4);
        let expected_4 = "\n  σ°„΅ Create JSON schema file for structured output\n  σ°„΅ Create system prompt template\n  σ°„΅ Create user prompt template\n  σ°„— Test with 2-3 cases first\n  σ°„± Run for all cases\n";
        assert_eq!(actual_4, expected_4);
    }
}