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
12fn 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 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
70fn 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
82pub 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#[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 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 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#[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
374fn 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 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#[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 #[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 #[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, )
556 .unwrap(),
557 );
558
559 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 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 #[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, )
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}