Skip to main content

track_core/
task_description.rs

1// =============================================================================
2// Task Description Sections
3// =============================================================================
4//
5// Tasks are stored as human-readable Markdown, but some workflows need a more
6// structured view of that Markdown. In particular:
7// - the task list wants a concise title
8// - remote dispatch wants a clear separation between the model-shaped summary
9//   and the original raw note from the user
10//
11// Instead of introducing a second persisted format, we keep one Markdown body
12// contract and provide tolerant parsing helpers around a couple of headings.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct TaskDescriptionSections {
15    pub title: String,
16    pub summary_markdown: Option<String>,
17    pub original_note: Option<String>,
18}
19
20pub fn append_follow_up_request(
21    description: &str,
22    timestamp_label: &str,
23    follow_up_request: &str,
24) -> String {
25    let normalized_description = description.trim();
26    let normalized_follow_up_request = follow_up_request.trim();
27    if normalized_follow_up_request.is_empty() {
28        return normalized_description.to_owned();
29    }
30
31    let follow_up_block = format!("### {timestamp_label}\n\n{normalized_follow_up_request}");
32
33    if normalized_description.contains("## Follow-up requests") {
34        format!("{normalized_description}\n\n{follow_up_block}")
35    } else if normalized_description.is_empty() {
36        format!("## Follow-up requests\n\n{follow_up_block}")
37    } else {
38        format!("{normalized_description}\n\n## Follow-up requests\n\n{follow_up_block}")
39    }
40}
41
42pub fn render_task_description(
43    title: &str,
44    summary_markdown: Option<&str>,
45    original_note: Option<&str>,
46) -> String {
47    let normalized_title = title.trim();
48    let normalized_summary = summary_markdown
49        .map(str::trim)
50        .filter(|value| !value.is_empty());
51    let normalized_original_note = original_note
52        .map(str::trim)
53        .filter(|value| !value.is_empty());
54
55    let mut sections = vec![normalized_title.to_owned()];
56    if let Some(summary_markdown) = normalized_summary {
57        sections.push(format!("## Summary\n\n{summary_markdown}"));
58    }
59
60    if let Some(original_note) = normalized_original_note {
61        sections.push(format!(
62            "## Original note\n\n{}",
63            quote_as_blockquote(original_note)
64        ));
65    }
66
67    sections.join("\n\n")
68}
69
70pub fn parse_task_description(description: &str) -> TaskDescriptionSections {
71    let normalized = description.trim();
72    let normalized_without_follow_ups = strip_markdown_section(normalized, "Follow-up requests");
73    let mut lines = normalized.lines();
74    let title = lines
75        .find(|line| !line.trim().is_empty())
76        .map(|line| line.trim().to_owned())
77        .unwrap_or_default();
78
79    let summary_markdown = extract_markdown_section(normalized, "Summary");
80    let original_note =
81        extract_markdown_section(normalized, "Original note").map(unquote_blockquote);
82
83    TaskDescriptionSections {
84        title: if title.is_empty() {
85            normalized.to_owned()
86        } else {
87            title
88        },
89        summary_markdown: summary_markdown.or_else(|| {
90            if normalized_without_follow_ups.is_empty() {
91                None
92            } else {
93                Some(normalized_without_follow_ups.to_owned())
94            }
95        }),
96        original_note,
97    }
98}
99
100fn extract_markdown_section(description: &str, heading: &str) -> Option<String> {
101    let marker = format!("## {heading}");
102    let start = description.find(&marker)?;
103    let after_heading = &description[start + marker.len()..];
104    let after_heading = after_heading.trim_start_matches([' ', '\t', '\r', '\n']);
105    if after_heading.is_empty() {
106        return None;
107    }
108
109    let end = after_heading.find("\n## ").unwrap_or(after_heading.len());
110    let section = after_heading[..end].trim();
111    if section.is_empty() {
112        None
113    } else {
114        Some(section.to_owned())
115    }
116}
117
118fn strip_markdown_section<'a>(description: &'a str, heading: &str) -> &'a str {
119    let marker = format!("\n## {heading}");
120    description
121        .find(&marker)
122        .map(|index| description[..index].trim_end())
123        .unwrap_or(description)
124}
125
126fn quote_as_blockquote(value: &str) -> String {
127    value
128        .lines()
129        .map(|line| {
130            if line.trim().is_empty() {
131                ">".to_owned()
132            } else {
133                format!("> {}", line)
134            }
135        })
136        .collect::<Vec<_>>()
137        .join("\n")
138}
139
140fn unquote_blockquote(value: String) -> String {
141    value
142        .lines()
143        .map(|line| {
144            line.trim_start()
145                .strip_prefix('>')
146                .map(str::trim_start)
147                .unwrap_or(line)
148        })
149        .collect::<Vec<_>>()
150        .join("\n")
151        .trim()
152        .to_owned()
153}
154
155#[cfg(test)]
156mod tests {
157    use super::{append_follow_up_request, parse_task_description, render_task_description};
158
159    #[test]
160    fn renders_summary_and_original_note_sections() {
161        let description = render_task_description(
162            "Fix a bug in module A",
163            Some("- Inspect `module_a.rs`"),
164            Some("proj-x prio high fix a bug in module A"),
165        );
166
167        assert_eq!(
168            description,
169            "Fix a bug in module A\n\n## Summary\n\n- Inspect `module_a.rs`\n\n## Original note\n\n> proj-x prio high fix a bug in module A"
170        );
171    }
172
173    #[test]
174    fn parses_task_description_sections_from_markdown() {
175        let sections = parse_task_description(
176            "Fix a bug in module A\n\n## Summary\n\n- Inspect `module_a.rs`\n\n## Original note\n\n> proj-x prio high fix a bug in module A",
177        );
178
179        assert_eq!(sections.title, "Fix a bug in module A");
180        assert_eq!(
181            sections.summary_markdown,
182            Some("- Inspect `module_a.rs`".to_owned())
183        );
184        assert_eq!(
185            sections.original_note,
186            Some("proj-x prio high fix a bug in module A".to_owned())
187        );
188    }
189
190    #[test]
191    fn falls_back_to_the_full_body_when_sections_are_missing() {
192        let sections = parse_task_description("Investigate flaky integration test");
193
194        assert_eq!(sections.title, "Investigate flaky integration test");
195        assert_eq!(
196            sections.summary_markdown,
197            Some("Investigate flaky integration test".to_owned())
198        );
199        assert_eq!(sections.original_note, None);
200    }
201
202    #[test]
203    fn appends_follow_up_requests_without_overwriting_existing_context() {
204        let updated = append_follow_up_request(
205            "Fix a bug in module A\n\n## Summary\n\n- Inspect `module_a.rs`",
206            "2026-03-18T14:00:00Z",
207            "Address review comments on the PR.",
208        );
209
210        assert!(updated.contains("## Summary"));
211        assert!(updated.contains("## Follow-up requests"));
212        assert!(updated.contains("### 2026-03-18T14:00:00Z"));
213        assert!(updated.contains("Address review comments on the PR."));
214    }
215
216    #[test]
217    fn fallback_summary_ignores_follow_up_history() {
218        let sections = parse_task_description(
219            "Investigate flaky integration test\n\nNeed to inspect retry logic.\n\n## Follow-up requests\n\n### 2026-03-18T14:00:00Z\n\nAddress review comments.",
220        );
221
222        assert_eq!(sections.title, "Investigate flaky integration test");
223        assert_eq!(
224            sections.summary_markdown,
225            Some("Investigate flaky integration test\n\nNeed to inspect retry logic.".to_owned())
226        );
227    }
228}