Skip to main content

hyalo_cli/commands/
tasks.rs

1#![allow(clippy::missing_errors_doc)]
2use anyhow::{Result, bail};
3use std::path::Path;
4
5use crate::commands::resolve_error_to_outcome;
6use crate::output::{CommandOutcome, Format};
7use hyalo_core::discovery;
8use hyalo_core::heading::{SectionFilter, parse_atx_heading};
9use hyalo_core::index::{SnapshotIndex, format_modified};
10use hyalo_core::types::{TaskDryRunResult, TaskInfo, TaskReadResult};
11
12// ---------------------------------------------------------------------------
13// Output types
14// ---------------------------------------------------------------------------
15
16// ---------------------------------------------------------------------------
17// Selector resolution
18// ---------------------------------------------------------------------------
19
20/// Resolve task selectors to a sorted, deduplicated list of 1-based line numbers.
21fn resolve_task_lines(
22    full_path: &Path,
23    lines: &[usize],
24    section: Option<&str>,
25    all: bool,
26) -> Result<Vec<usize>> {
27    if !lines.is_empty() {
28        let mut sorted = lines.to_vec();
29        sorted.sort_unstable();
30        sorted.dedup();
31        return Ok(sorted);
32    }
33
34    if let Some(section_str) = section {
35        let filter = SectionFilter::parse(section_str)
36            .map_err(|e| anyhow::anyhow!("invalid --section: {e}"))?;
37        let tasks = hyalo_core::tasks::find_task_lines(full_path)?;
38        let matched: Vec<usize> = tasks
39            .iter()
40            .filter(|t| {
41                // t.section is formatted as "## heading text" — parse it back
42                if t.section.is_empty() {
43                    return false;
44                }
45                if let Some((level, text)) = parse_atx_heading(&t.section) {
46                    filter.matches(level, text)
47                } else {
48                    false
49                }
50            })
51            .map(|t| t.line)
52            .collect();
53        if matched.is_empty() {
54            bail!("no tasks found in section {section_str:?}");
55        }
56        return Ok(matched);
57    }
58
59    if all {
60        let tasks = hyalo_core::tasks::find_task_lines(full_path)?;
61        if tasks.is_empty() {
62            bail!("no tasks found in file");
63        }
64        return Ok(tasks.iter().map(|t| t.line).collect());
65    }
66
67    bail!("specify at least one of --line, --section, or --all")
68}
69
70/// Format a slice of results: single object when exactly 1 element, Vec when
71/// multiple. The output pipeline later wraps this in the
72/// `{"results": ..., "hints": [...]}` envelope. Generic over the result type
73/// so both `TaskReadResult` and `TaskDryRunResult` share the same branching.
74fn format_one_or_many<T: serde::Serialize>(results: &[T], format: Format) -> String {
75    if let [single] = results {
76        crate::output::format_output(format, single)
77    } else {
78        crate::output::format_output(format, &results)
79    }
80}
81
82// ---------------------------------------------------------------------------
83// `hyalo task read` — read task(s) at given line(s)
84// ---------------------------------------------------------------------------
85
86/// Read one or more tasks by line selector.
87pub fn task_read(
88    dir: &Path,
89    file_arg: &str,
90    lines: &[usize],
91    section: Option<&str>,
92    all: bool,
93    format: Format,
94) -> Result<CommandOutcome> {
95    let (full_path, rel_path) = match discovery::resolve_file(dir, file_arg) {
96        Ok(r) => r,
97        Err(e) => return Ok(resolve_error_to_outcome(e, format)),
98    };
99
100    let resolved = match resolve_task_lines(&full_path, lines, section, all) {
101        Ok(v) => v,
102        Err(e) => {
103            let msg = e.to_string();
104            let out = crate::output::format_error(
105                format,
106                &msg,
107                Some(&rel_path),
108                Some(
109                    "use `hyalo find --task any --file <path>` to list all tasks with their line numbers",
110                ),
111                None,
112            );
113            return Ok(CommandOutcome::UserError(out));
114        }
115    };
116
117    let mut results = Vec::with_capacity(resolved.len());
118    for line in resolved {
119        match hyalo_core::tasks::read_task(&full_path, line)? {
120            None => {
121                let msg = format!("line {line} is not a task");
122                let out = crate::output::format_error(
123                    format,
124                    &msg,
125                    Some(&rel_path),
126                    Some(
127                        "use `hyalo find --task any --file <path>` to list all tasks with their line numbers",
128                    ),
129                    None,
130                );
131                return Ok(CommandOutcome::UserError(out));
132            }
133            Some(info) => {
134                results.push(TaskReadResult {
135                    file: rel_path.clone(),
136                    line: info.line,
137                    status: info.status,
138                    text: info.text,
139                    done: info.done,
140                });
141            }
142        }
143    }
144
145    Ok(CommandOutcome::success(format_one_or_many(
146        &results, format,
147    )))
148}
149
150// ---------------------------------------------------------------------------
151// `hyalo task toggle` — toggle task completion
152// ---------------------------------------------------------------------------
153
154/// Toggle one or more tasks by line selector.
155#[allow(clippy::too_many_arguments)]
156pub fn task_toggle(
157    dir: &Path,
158    file_arg: &str,
159    lines: &[usize],
160    section: Option<&str>,
161    all: bool,
162    format: Format,
163    snapshot_index: &mut Option<SnapshotIndex>,
164    index_path: Option<&Path>,
165    dry_run: bool,
166) -> Result<CommandOutcome> {
167    let (full_path, rel_path) = match discovery::resolve_file(dir, file_arg) {
168        Ok(r) => r,
169        Err(e) => return Ok(resolve_error_to_outcome(e, format)),
170    };
171
172    let resolved = match resolve_task_lines(&full_path, lines, section, all) {
173        Ok(v) => v,
174        Err(e) => {
175            let msg = e.to_string();
176            return Ok(CommandOutcome::UserError(crate::output::format_error(
177                format,
178                &msg,
179                Some(&rel_path),
180                None,
181                None,
182            )));
183        }
184    };
185
186    if dry_run {
187        // In dry-run mode: compute the toggled state without writing to disk.
188        //
189        // Single-pass scan: collect every task in the file once, then look up
190        // each resolved target line. Avoids O(n * file_length) from calling
191        // `read_task` per line when --all or a large --line list is used.
192        //
193        // We emit `TaskDryRunResult` (carrying both `old_status` and `status`)
194        // so the text formatter can render `"file":line [old] -> [new] text`
195        // and make the direction of change explicit. The dispatch layer always
196        // forces JSON here; text rendering happens later in the output
197        // pipeline via a shape-specific jq filter.
198        let tasks_by_line: std::collections::HashMap<usize, hyalo_core::types::FindTaskInfo> =
199            hyalo_core::tasks::find_task_lines(&full_path)?
200                .into_iter()
201                .map(|t| (t.line, t))
202                .collect();
203        let mut results: Vec<TaskDryRunResult> = Vec::with_capacity(resolved.len());
204        for &line_num in &resolved {
205            match tasks_by_line.get(&line_num) {
206                None => {
207                    let msg = format!("line {line_num} is not a task");
208                    return Ok(CommandOutcome::UserError(crate::output::format_error(
209                        format,
210                        &msg,
211                        Some(&rel_path),
212                        None,
213                        None,
214                    )));
215                }
216                Some(info) => {
217                    // Simulate what toggle would do: flip done state.
218                    let new_done = !info.done;
219                    let new_status = if new_done { 'x' } else { ' ' };
220                    results.push(TaskDryRunResult {
221                        file: rel_path.clone(),
222                        line: info.line,
223                        old_status: info.status,
224                        status: new_status,
225                        text: info.text.clone(),
226                        done: new_done,
227                    });
228                }
229            }
230        }
231        return Ok(CommandOutcome::success(format_one_or_many(
232            &results, format,
233        )));
234    }
235
236    match hyalo_core::tasks::toggle_tasks(&full_path, &resolved) {
237        Ok(infos) => {
238            for info in &infos {
239                patch_index(&full_path, &rel_path, info, snapshot_index, index_path)?;
240            }
241            let results: Vec<TaskReadResult> = infos
242                .into_iter()
243                .map(|info| TaskReadResult {
244                    file: rel_path.clone(),
245                    line: info.line,
246                    status: info.status,
247                    text: info.text,
248                    done: info.done,
249                })
250                .collect();
251            Ok(CommandOutcome::success(format_one_or_many(
252                &results, format,
253            )))
254        }
255        Err(e) => {
256            let msg = e.to_string();
257            Ok(CommandOutcome::UserError(crate::output::format_error(
258                format,
259                &msg,
260                Some(&rel_path),
261                None,
262                None,
263            )))
264        }
265    }
266}
267
268// ---------------------------------------------------------------------------
269// `hyalo task set` — set custom status character
270// ---------------------------------------------------------------------------
271
272/// Set status on one or more tasks by line selector.
273#[allow(clippy::too_many_arguments)]
274pub fn task_set_status(
275    dir: &Path,
276    file_arg: &str,
277    lines: &[usize],
278    section: Option<&str>,
279    all: bool,
280    status: char,
281    format: Format,
282    snapshot_index: &mut Option<SnapshotIndex>,
283    index_path: Option<&Path>,
284    dry_run: bool,
285) -> Result<CommandOutcome> {
286    let (full_path, rel_path) = match discovery::resolve_file(dir, file_arg) {
287        Ok(r) => r,
288        Err(e) => return Ok(resolve_error_to_outcome(e, format)),
289    };
290
291    let resolved = match resolve_task_lines(&full_path, lines, section, all) {
292        Ok(v) => v,
293        Err(e) => {
294            let msg = e.to_string();
295            return Ok(CommandOutcome::UserError(crate::output::format_error(
296                format,
297                &msg,
298                Some(&rel_path),
299                None,
300                None,
301            )));
302        }
303    };
304
305    if dry_run {
306        let tasks_by_line: std::collections::HashMap<usize, hyalo_core::types::FindTaskInfo> =
307            hyalo_core::tasks::find_task_lines(&full_path)?
308                .into_iter()
309                .map(|t| (t.line, t))
310                .collect();
311        let mut results: Vec<TaskDryRunResult> = Vec::with_capacity(resolved.len());
312        for &line_num in &resolved {
313            match tasks_by_line.get(&line_num) {
314                None => {
315                    let msg = format!("line {line_num} is not a task");
316                    return Ok(CommandOutcome::UserError(crate::output::format_error(
317                        format,
318                        &msg,
319                        Some(&rel_path),
320                        None,
321                        None,
322                    )));
323                }
324                Some(info) => {
325                    let new_done = status == 'x' || status == 'X';
326                    results.push(TaskDryRunResult {
327                        file: rel_path.clone(),
328                        line: info.line,
329                        old_status: info.status,
330                        status,
331                        text: info.text.clone(),
332                        done: new_done,
333                    });
334                }
335            }
336        }
337        return Ok(CommandOutcome::success(format_one_or_many(
338            &results, format,
339        )));
340    }
341
342    match hyalo_core::tasks::set_tasks_status(&full_path, &resolved, status) {
343        Ok(infos) => {
344            for info in &infos {
345                patch_index(&full_path, &rel_path, info, snapshot_index, index_path)?;
346            }
347            let results: Vec<TaskReadResult> = infos
348                .into_iter()
349                .map(|info| TaskReadResult {
350                    file: rel_path.clone(),
351                    line: info.line,
352                    status: info.status,
353                    text: info.text,
354                    done: info.done,
355                })
356                .collect();
357            Ok(CommandOutcome::success(format_one_or_many(
358                &results, format,
359            )))
360        }
361        Err(e) => {
362            let msg = e.to_string();
363            Ok(CommandOutcome::UserError(crate::output::format_error(
364                format,
365                &msg,
366                Some(&rel_path),
367                None,
368                None,
369            )))
370        }
371    }
372}
373
374// ---------------------------------------------------------------------------
375// Index patching helper
376// ---------------------------------------------------------------------------
377
378fn patch_index(
379    full_path: &Path,
380    rel_path: &str,
381    info: &TaskInfo,
382    snapshot_index: &mut Option<SnapshotIndex>,
383    index_path: Option<&Path>,
384) -> Result<()> {
385    if let (Some(idx), Some(idx_path)) = (snapshot_index.as_mut(), index_path) {
386        if let Some(entry) = idx.get_mut(rel_path) {
387            if let Some(task) = entry.tasks.iter_mut().find(|t| t.line == info.line) {
388                task.status = info.status;
389                task.done = info.done;
390            }
391            // Rebuild section task counts from the updated task list.
392            // Each section owns the range [section.line, next_section.line).
393            let section_starts: Vec<usize> = entry.sections.iter().map(|s| s.line).collect();
394            for (si, section) in entry.sections.iter_mut().enumerate() {
395                let start = section_starts[si];
396                let end = section_starts.get(si + 1).copied().unwrap_or(usize::MAX);
397                let total = entry
398                    .tasks
399                    .iter()
400                    .filter(|t| t.line >= start && t.line < end)
401                    .count();
402                if total > 0 {
403                    let done = entry
404                        .tasks
405                        .iter()
406                        .filter(|t| t.line >= start && t.line < end && t.done)
407                        .count();
408                    section.tasks = Some(hyalo_core::types::TaskCount { total, done });
409                } else {
410                    section.tasks = None;
411                }
412            }
413            entry.modified = format_modified(full_path)?;
414        }
415        idx.save_to(idx_path)?;
416    }
417    Ok(())
418}
419
420// ---------------------------------------------------------------------------
421// Unit tests
422// ---------------------------------------------------------------------------
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use std::fs;
428
429    fn unwrap_success(outcome: CommandOutcome) -> String {
430        match outcome {
431            CommandOutcome::Success { output: s, .. } | CommandOutcome::RawOutput(s) => s,
432            CommandOutcome::UserError(s) => panic!("expected success, got user error: {s}"),
433        }
434    }
435
436    // --- task_read ---
437
438    #[test]
439    fn task_read_finds_task() {
440        let tmp = tempfile::tempdir().unwrap();
441        fs::write(tmp.path().join("note.md"), "- [ ] My task\n").unwrap();
442        let out = unwrap_success(
443            task_read(tmp.path(), "note.md", &[1], None, false, Format::Json).unwrap(),
444        );
445        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
446        assert_eq!(parsed["line"], 1);
447        assert_eq!(parsed["status"], " ");
448        assert_eq!(parsed["text"], "My task");
449        assert_eq!(parsed["done"], false);
450        assert!(parsed["file"].as_str().unwrap().ends_with("note.md"));
451    }
452
453    #[test]
454    fn task_read_non_task_line_returns_user_error() {
455        let tmp = tempfile::tempdir().unwrap();
456        fs::write(tmp.path().join("note.md"), "Just a regular line\n").unwrap();
457        let outcome = task_read(tmp.path(), "note.md", &[1], None, false, Format::Json).unwrap();
458        assert!(matches!(outcome, CommandOutcome::UserError(_)));
459    }
460
461    #[test]
462    fn task_read_file_not_found() {
463        let tmp = tempfile::tempdir().unwrap();
464        let outcome = task_read(tmp.path(), "nope.md", &[1], None, false, Format::Json).unwrap();
465        assert!(matches!(outcome, CommandOutcome::UserError(_)));
466    }
467
468    // --- task_toggle ---
469
470    #[test]
471    fn task_toggle_open_to_done() {
472        let tmp = tempfile::tempdir().unwrap();
473        fs::write(tmp.path().join("note.md"), "- [ ] My task\n").unwrap();
474        let out = unwrap_success(
475            task_toggle(
476                tmp.path(),
477                "note.md",
478                &[1],
479                None,
480                false,
481                Format::Json,
482                &mut None,
483                None,
484                false,
485            )
486            .unwrap(),
487        );
488        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
489        assert_eq!(parsed["status"], "x");
490        assert_eq!(parsed["done"], true);
491
492        let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
493        assert!(content.contains("- [x] My task"));
494    }
495
496    #[test]
497    fn task_toggle_done_to_open() {
498        let tmp = tempfile::tempdir().unwrap();
499        fs::write(tmp.path().join("note.md"), "- [x] Done task\n").unwrap();
500        let out = unwrap_success(
501            task_toggle(
502                tmp.path(),
503                "note.md",
504                &[1],
505                None,
506                false,
507                Format::Json,
508                &mut None,
509                None,
510                false,
511            )
512            .unwrap(),
513        );
514        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
515        assert_eq!(parsed["status"], " ");
516        assert_eq!(parsed["done"], false);
517    }
518
519    #[test]
520    fn task_toggle_non_task_returns_user_error() {
521        let tmp = tempfile::tempdir().unwrap();
522        fs::write(tmp.path().join("note.md"), "Not a task\n").unwrap();
523        let outcome = task_toggle(
524            tmp.path(),
525            "note.md",
526            &[1],
527            None,
528            false,
529            Format::Json,
530            &mut None,
531            None,
532            false,
533        )
534        .unwrap();
535        assert!(matches!(outcome, CommandOutcome::UserError(_)));
536    }
537
538    #[test]
539    fn task_toggle_dry_run_does_not_modify_file() {
540        let tmp = tempfile::tempdir().unwrap();
541        let original = "- [ ] My task\n";
542        fs::write(tmp.path().join("note.md"), original).unwrap();
543
544        let out = unwrap_success(
545            task_toggle(
546                tmp.path(),
547                "note.md",
548                &[1],
549                None,
550                false,
551                Format::Json,
552                &mut None,
553                None,
554                true, // dry_run
555            )
556            .unwrap(),
557        );
558
559        // Output should reflect the toggled state (done=true)
560        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
561        assert_eq!(parsed["status"], "x");
562        assert_eq!(parsed["done"], true);
563
564        // But the file on disk must be unchanged
565        let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
566        assert_eq!(content, original, "file was modified during --dry-run");
567    }
568
569    // --- task_set_status ---
570
571    #[test]
572    fn task_set_status_custom_char() {
573        let tmp = tempfile::tempdir().unwrap();
574        fs::write(tmp.path().join("note.md"), "- [ ] My task\n").unwrap();
575        let out = unwrap_success(
576            task_set_status(
577                tmp.path(),
578                "note.md",
579                &[1],
580                None,
581                false,
582                '?',
583                Format::Json,
584                &mut None,
585                None,
586                false,
587            )
588            .unwrap(),
589        );
590        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
591        assert_eq!(parsed["status"], "?");
592        assert_eq!(parsed["done"], false);
593
594        let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
595        assert!(content.contains("- [?] My task"));
596    }
597
598    #[test]
599    fn task_set_status_to_done() {
600        let tmp = tempfile::tempdir().unwrap();
601        fs::write(tmp.path().join("note.md"), "- [ ] My task\n").unwrap();
602        let out = unwrap_success(
603            task_set_status(
604                tmp.path(),
605                "note.md",
606                &[1],
607                None,
608                false,
609                'x',
610                Format::Json,
611                &mut None,
612                None,
613                false,
614            )
615            .unwrap(),
616        );
617        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
618        assert_eq!(parsed["status"], "x");
619        assert_eq!(parsed["done"], true);
620    }
621
622    #[test]
623    fn task_set_status_non_task_returns_user_error() {
624        let tmp = tempfile::tempdir().unwrap();
625        fs::write(tmp.path().join("note.md"), "# Heading\n").unwrap();
626        let outcome = task_set_status(
627            tmp.path(),
628            "note.md",
629            &[1],
630            None,
631            false,
632            'x',
633            Format::Json,
634            &mut None,
635            None,
636            false,
637        )
638        .unwrap();
639        assert!(matches!(outcome, CommandOutcome::UserError(_)));
640    }
641
642    #[test]
643    fn task_set_status_dry_run_does_not_write() {
644        let tmp = tempfile::tempdir().unwrap();
645        let original = "- [ ] My task\n";
646        fs::write(tmp.path().join("note.md"), original).unwrap();
647        let out = unwrap_success(
648            task_set_status(
649                tmp.path(),
650                "note.md",
651                &[1],
652                None,
653                false,
654                '?',
655                Format::Json,
656                &mut None,
657                None,
658                true, // dry_run
659            )
660            .unwrap(),
661        );
662        assert!(out.contains("old_status"));
663        assert!(out.contains("\"status\": \"?\"") || out.contains("\"status\":\"?\""));
664        let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
665        assert_eq!(content, original, "file was modified during --dry-run");
666    }
667}