Skip to main content

ito_core/
backend_http.rs

1//! HTTP client for backend repository reads and task mutations.
2
3use std::collections::BTreeSet;
4use std::io::{Error as IoError, ErrorKind};
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::time::Duration;
8
9use chrono::{DateTime, Utc};
10use serde::Deserialize;
11use serde::de::DeserializeOwned;
12
13use crate::audit::AuditEvent;
14use crate::backend_client::{BackendRuntime, is_retriable_status};
15use ito_domain::backend::{
16    ArchiveResult, ArtifactBundle, BackendArchiveClient, BackendChangeReader,
17    BackendEventIngestClient, BackendModuleReader, BackendSpecReader, BackendSyncClient,
18    EventBatch, EventIngestResult, PushResult,
19};
20use ito_domain::changes::{
21    Change, ChangeLifecycleFilter, ChangeSummary, Spec, extract_sub_module_id,
22};
23use ito_domain::errors::{DomainError, DomainResult};
24use ito_domain::modules::{Module, ModuleSummary, SubModule, SubModuleSummary};
25use ito_domain::specs::{SpecDocument, SpecSummary};
26use ito_domain::tasks::{
27    DiagnosticLevel, ProgressInfo, TaskDiagnostic, TaskInitResult, TaskItem, TaskKind,
28    TaskMutationError, TaskMutationResult, TaskMutationService, TaskMutationServiceResult,
29    TaskStatus, TasksFormat, TasksParseResult, WaveInfo,
30};
31
32/// Backend HTTP client shared across repository adapters.
33#[derive(Debug, Clone)]
34pub struct BackendHttpClient {
35    inner: Arc<BackendHttpClientInner>,
36}
37
38#[derive(Debug)]
39struct BackendHttpClientInner {
40    runtime: BackendRuntime,
41    agent: ureq::Agent,
42}
43
44impl BackendHttpClient {
45    /// Create a backend HTTP client from a resolved runtime.
46    pub fn new(runtime: BackendRuntime) -> Self {
47        let agent = ureq::Agent::config_builder()
48            .timeout_global(Some(runtime.timeout))
49            .http_status_as_error(false)
50            .build()
51            .into();
52        Self {
53            inner: Arc::new(BackendHttpClientInner { runtime, agent }),
54        }
55    }
56
57    pub(crate) fn load_tasks_parse_result(
58        &self,
59        change_id: &str,
60    ) -> DomainResult<TasksParseResult> {
61        let url = format!(
62            "{}/changes/{change_id}/tasks",
63            self.inner.runtime.project_api_prefix()
64        );
65        let list: ApiTaskList = self.get_json(&url, "task", Some(change_id))?;
66        Ok(task_list_to_parse_result(list))
67    }
68
69    /// Read backend-managed audit events for the current project.
70    pub fn list_audit_events(&self) -> Result<Vec<AuditEvent>, ito_domain::backend::BackendError> {
71        let url = format!("{}/events", self.inner.runtime.project_api_prefix());
72        self.backend_get_json(&url)
73    }
74
75    /// Submit audit events directly to the backend for the current project.
76    pub fn ingest_event_batch(
77        &self,
78        batch: &EventBatch,
79    ) -> Result<EventIngestResult, ito_domain::backend::BackendError> {
80        let url = format!("{}/events", self.inner.runtime.project_api_prefix());
81        let body = serde_json::to_string(batch)
82            .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
83        self.backend_post_json(&url, Some(&body), true)
84    }
85
86    fn get_json<T: DeserializeOwned>(
87        &self,
88        url: &str,
89        entity: &'static str,
90        id: Option<&str>,
91    ) -> DomainResult<T> {
92        let response = self.request_with_retry("GET", url, None, false)?;
93        let status = response.status().as_u16();
94        let body = read_response_body(response)?;
95        if status != 200 {
96            return Err(map_status_to_domain_error(status, entity, id, &body));
97        }
98        serde_json::from_str(&body)
99            .map_err(|err| DomainError::io("parsing backend response", IoError::other(err)))
100    }
101
102    fn task_get_json<T: DeserializeOwned>(&self, url: &str) -> TaskMutationServiceResult<T> {
103        let response = self
104            .request_with_retry("GET", url, None, false)
105            .map_err(task_error_from_domain)?;
106        parse_task_response(response)
107    }
108
109    fn task_post_json<T: DeserializeOwned>(
110        &self,
111        url: &str,
112        body: Option<&str>,
113    ) -> TaskMutationServiceResult<T> {
114        let response = self
115            .request_with_retry("POST", url, body, false)
116            .map_err(task_error_from_domain)?;
117        parse_task_response(response)
118    }
119
120    fn backend_get_json<T: DeserializeOwned>(
121        &self,
122        url: &str,
123    ) -> Result<T, ito_domain::backend::BackendError> {
124        let response = self
125            .request_with_retry("GET", url, None, false)
126            .map_err(backend_error_from_domain)?;
127        parse_backend_response(response)
128    }
129
130    fn backend_post_json<T: DeserializeOwned>(
131        &self,
132        url: &str,
133        body: Option<&str>,
134        retry_post: bool,
135    ) -> Result<T, ito_domain::backend::BackendError> {
136        let response = self
137            .request_with_retry("POST", url, body, retry_post)
138            .map_err(backend_error_from_domain)?;
139        parse_backend_response(response)
140    }
141
142    fn request_with_retry(
143        &self,
144        method: &str,
145        url: &str,
146        body: Option<&str>,
147        retry_post: bool,
148    ) -> DomainResult<ureq::http::Response<ureq::Body>> {
149        let max_retries = self.inner.runtime.max_retries;
150        let retries_enabled = request_retries_enabled(method, retry_post);
151        let mut attempt = 0u32;
152        loop {
153            let response: Result<ureq::http::Response<ureq::Body>, ureq::Error> = match method {
154                "GET" => self
155                    .inner
156                    .agent
157                    .get(url)
158                    .header(
159                        "Authorization",
160                        &format!("Bearer {}", self.inner.runtime.token),
161                    )
162                    .call(),
163                "POST" => {
164                    // In ureq v3, POST always uses send() — use empty string when no body.
165                    let payload = body.unwrap_or("{}");
166                    self.inner
167                        .agent
168                        .post(url)
169                        .header(
170                            "Authorization",
171                            &format!("Bearer {}", self.inner.runtime.token),
172                        )
173                        .header("Content-Type", "application/json")
174                        .send(payload)
175                }
176                _ => unreachable!("unsupported backend http method"),
177            };
178
179            match response {
180                Ok(resp) => {
181                    let status: u16 = resp.status().as_u16();
182                    if retries_enabled && is_retriable_status(status) && attempt < max_retries {
183                        attempt += 1;
184                        sleep_backoff(attempt);
185                        continue;
186                    }
187                    return Ok(resp);
188                }
189                Err(err) => {
190                    if retries_enabled && attempt < max_retries {
191                        attempt += 1;
192                        sleep_backoff(attempt);
193                        continue;
194                    }
195                    return Err(DomainError::io(
196                        "backend request",
197                        IoError::other(err.to_string()),
198                    ));
199                }
200            }
201        }
202    }
203}
204
205fn optional_task_text_body(field: &str, value: Option<String>) -> String {
206    match value {
207        Some(value) => serde_json::json!({ field: value }).to_string(),
208        None => "{}".to_string(),
209    }
210}
211
212fn retries_enabled_by_default(method: &str) -> bool {
213    match method {
214        "GET" => true,
215        "POST" => false,
216        "PUT" => false,
217        "PATCH" => false,
218        "DELETE" => false,
219        "HEAD" => false,
220        "OPTIONS" => false,
221        "TRACE" => false,
222        _ => false,
223    }
224}
225
226fn request_retries_enabled(method: &str, retry_post: bool) -> bool {
227    if method == "POST" {
228        return retry_post;
229    }
230
231    retries_enabled_by_default(method)
232}
233
234fn is_not_found_error(err: &DomainError) -> bool {
235    matches!(err, DomainError::NotFound { .. })
236}
237
238impl BackendChangeReader for BackendHttpClient {
239    fn list_changes(&self, filter: ChangeLifecycleFilter) -> DomainResult<Vec<ChangeSummary>> {
240        let url = format!(
241            "{}/changes?lifecycle={}",
242            self.inner.runtime.project_api_prefix(),
243            filter.as_str()
244        );
245        let summaries: Vec<ApiChangeSummary> = self.get_json(&url, "change", None)?;
246        let mut out = Vec::with_capacity(summaries.len());
247        for summary in summaries {
248            let last_modified = parse_timestamp(&summary.last_modified)?;
249            let sub_module_id = extract_sub_module_id(&summary.id);
250            out.push(ChangeSummary {
251                id: summary.id,
252                module_id: summary.module_id,
253                sub_module_id,
254                completed_tasks: summary.completed_tasks,
255                shelved_tasks: summary.shelved_tasks,
256                in_progress_tasks: summary.in_progress_tasks,
257                pending_tasks: summary.pending_tasks,
258                total_tasks: summary.total_tasks,
259                last_modified,
260                has_proposal: summary.has_proposal,
261                has_design: summary.has_design,
262                has_specs: summary.has_specs,
263                has_tasks: summary.has_tasks,
264            });
265        }
266        Ok(out)
267    }
268
269    fn get_change(&self, change_id: &str, filter: ChangeLifecycleFilter) -> DomainResult<Change> {
270        let url = format!(
271            "{}/changes/{change_id}?lifecycle={}",
272            self.inner.runtime.project_api_prefix(),
273            filter.as_str()
274        );
275        let change: ApiChange = self.get_json(&url, "change", Some(change_id))?;
276        let tasks = match self.load_tasks_parse_result(change_id) {
277            Ok(tasks) => tasks,
278            Err(err) => {
279                if filter.includes_archived() && is_not_found_error(&err) {
280                    tasks_from_progress(&change.progress)
281                } else {
282                    return Err(err);
283                }
284            }
285        };
286        let last_modified = parse_timestamp(&change.last_modified)?;
287        let sub_module_id = extract_sub_module_id(&change.id);
288        Ok(Change {
289            id: change.id,
290            module_id: change.module_id,
291            sub_module_id,
292            path: PathBuf::new(),
293            proposal: change.proposal,
294            design: change.design,
295            specs: {
296                let mut specs = Vec::with_capacity(change.specs.len());
297                for spec in change.specs {
298                    specs.push(Spec {
299                        name: spec.name,
300                        content: spec.content,
301                    });
302                }
303                specs
304            },
305            tasks,
306            last_modified,
307        })
308    }
309}
310
311impl BackendModuleReader for BackendHttpClient {
312    fn list_modules(&self) -> DomainResult<Vec<ModuleSummary>> {
313        let url = format!("{}/modules", self.inner.runtime.project_api_prefix());
314        let modules: Vec<ApiModuleSummary> = self.get_json(&url, "module", None)?;
315        let mut out = Vec::with_capacity(modules.len());
316        for m in modules {
317            let mut sub_modules = Vec::with_capacity(m.sub_modules.len());
318            for s in m.sub_modules {
319                sub_modules.push(SubModuleSummary {
320                    id: s.id,
321                    name: s.name,
322                    change_count: s.change_count,
323                });
324            }
325            out.push(ModuleSummary {
326                id: m.id,
327                name: m.name,
328                change_count: m.change_count,
329                sub_modules,
330            });
331        }
332        Ok(out)
333    }
334
335    fn get_module(&self, module_id: &str) -> DomainResult<Module> {
336        let url = format!(
337            "{}/modules/{module_id}",
338            self.inner.runtime.project_api_prefix()
339        );
340        let module: ApiModule = self.get_json(&url, "module", Some(module_id))?;
341        let mut sub_modules = Vec::with_capacity(module.sub_modules.len());
342        for s in module.sub_modules {
343            let sub_id = s.id.split('.').nth(1).unwrap_or("").to_string();
344            sub_modules.push(SubModule {
345                id: s.id,
346                parent_module_id: module.id.clone(),
347                sub_id,
348                name: s.name,
349                description: s.description,
350                change_count: s.change_count,
351                path: PathBuf::new(),
352            });
353        }
354        Ok(Module {
355            id: module.id,
356            name: module.name,
357            description: module.description,
358            path: PathBuf::new(),
359            sub_modules,
360        })
361    }
362}
363
364impl BackendSpecReader for BackendHttpClient {
365    fn list_specs(&self) -> DomainResult<Vec<SpecSummary>> {
366        let url = format!("{}/specs", self.inner.runtime.project_api_prefix());
367        let specs: Vec<ApiSpecSummary> = self.get_json(&url, "spec", None)?;
368        let mut out = Vec::with_capacity(specs.len());
369        for spec in specs {
370            out.push(SpecSummary {
371                id: spec.id,
372                path: PathBuf::from(spec.path),
373                last_modified: parse_timestamp(&spec.last_modified)?,
374            });
375        }
376        Ok(out)
377    }
378
379    fn get_spec(&self, spec_id: &str) -> DomainResult<SpecDocument> {
380        let url = format!(
381            "{}/specs/{spec_id}",
382            self.inner.runtime.project_api_prefix()
383        );
384        let spec: ApiSpecDocument = self.get_json(&url, "spec", Some(spec_id))?;
385        Ok(SpecDocument {
386            id: spec.id,
387            path: PathBuf::from(spec.path),
388            markdown: spec.markdown,
389            last_modified: parse_timestamp(&spec.last_modified)?,
390        })
391    }
392}
393
394impl TaskMutationService for BackendHttpClient {
395    fn load_tasks_markdown(&self, change_id: &str) -> TaskMutationServiceResult<Option<String>> {
396        let url = format!(
397            "{}/changes/{change_id}/tasks/raw",
398            self.inner.runtime.project_api_prefix()
399        );
400        let response: ApiTaskMarkdown = self.task_get_json(&url)?;
401        Ok(response.content)
402    }
403
404    fn init_tasks(&self, change_id: &str) -> TaskMutationServiceResult<TaskInitResult> {
405        let url = format!(
406            "{}/changes/{change_id}/tasks/init",
407            self.inner.runtime.project_api_prefix()
408        );
409        let response: ApiTaskInitResult = self.task_post_json(&url, Some("{}"))?;
410        Ok(TaskInitResult {
411            change_id: response.change_id,
412            path: response.path.map(PathBuf::from),
413            existed: response.existed,
414            revision: response.revision,
415        })
416    }
417
418    fn start_task(
419        &self,
420        change_id: &str,
421        task_id: &str,
422    ) -> TaskMutationServiceResult<TaskMutationResult> {
423        let url = format!(
424            "{}/changes/{change_id}/tasks/{task_id}/start",
425            self.inner.runtime.project_api_prefix()
426        );
427        let response: ApiTaskMutationEnvelope = self.task_post_json(&url, Some("{}"))?;
428        Ok(task_mutation_from_api(response))
429    }
430
431    fn complete_task(
432        &self,
433        change_id: &str,
434        task_id: &str,
435        note: Option<String>,
436    ) -> TaskMutationServiceResult<TaskMutationResult> {
437        let url = format!(
438            "{}/changes/{change_id}/tasks/{task_id}/complete",
439            self.inner.runtime.project_api_prefix()
440        );
441        let body = optional_task_text_body("note", note);
442        let response: ApiTaskMutationEnvelope = self.task_post_json(&url, Some(&body))?;
443        Ok(task_mutation_from_api(response))
444    }
445
446    fn shelve_task(
447        &self,
448        change_id: &str,
449        task_id: &str,
450        reason: Option<String>,
451    ) -> TaskMutationServiceResult<TaskMutationResult> {
452        let url = format!(
453            "{}/changes/{change_id}/tasks/{task_id}/shelve",
454            self.inner.runtime.project_api_prefix()
455        );
456        let body = optional_task_text_body("reason", reason);
457        let response: ApiTaskMutationEnvelope = self.task_post_json(&url, Some(&body))?;
458        Ok(task_mutation_from_api(response))
459    }
460
461    fn unshelve_task(
462        &self,
463        change_id: &str,
464        task_id: &str,
465    ) -> TaskMutationServiceResult<TaskMutationResult> {
466        let url = format!(
467            "{}/changes/{change_id}/tasks/{task_id}/unshelve",
468            self.inner.runtime.project_api_prefix()
469        );
470        let response: ApiTaskMutationEnvelope = self.task_post_json(&url, Some("{}"))?;
471        Ok(task_mutation_from_api(response))
472    }
473
474    fn add_task(
475        &self,
476        change_id: &str,
477        title: &str,
478        wave: Option<u32>,
479    ) -> TaskMutationServiceResult<TaskMutationResult> {
480        let url = format!(
481            "{}/changes/{change_id}/tasks/add",
482            self.inner.runtime.project_api_prefix()
483        );
484        let body = serde_json::json!({ "title": title, "wave": wave }).to_string();
485        let response: ApiTaskMutationEnvelope = self.task_post_json(&url, Some(&body))?;
486        Ok(task_mutation_from_api(response))
487    }
488}
489
490impl BackendSyncClient for BackendHttpClient {
491    fn pull(&self, change_id: &str) -> Result<ArtifactBundle, ito_domain::backend::BackendError> {
492        let url = format!(
493            "{}/changes/{change_id}/sync",
494            self.inner.runtime.project_api_prefix()
495        );
496        self.backend_get_json(&url)
497    }
498
499    fn push(
500        &self,
501        change_id: &str,
502        bundle: &ArtifactBundle,
503    ) -> Result<PushResult, ito_domain::backend::BackendError> {
504        let url = format!(
505            "{}/changes/{change_id}/sync",
506            self.inner.runtime.project_api_prefix()
507        );
508        let body = serde_json::to_string(bundle)
509            .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
510        self.backend_post_json(&url, Some(&body), false)
511    }
512}
513
514impl BackendArchiveClient for BackendHttpClient {
515    fn mark_archived(
516        &self,
517        change_id: &str,
518    ) -> Result<ArchiveResult, ito_domain::backend::BackendError> {
519        let url = format!(
520            "{}/changes/{change_id}/archive",
521            self.inner.runtime.project_api_prefix()
522        );
523        self.backend_post_json(&url, Some("{}"), false)
524    }
525}
526
527impl BackendEventIngestClient for BackendHttpClient {
528    fn ingest(
529        &self,
530        batch: &EventBatch,
531    ) -> Result<EventIngestResult, ito_domain::backend::BackendError> {
532        self.ingest_event_batch(batch)
533    }
534}
535
536fn read_response_body(response: ureq::http::Response<ureq::Body>) -> DomainResult<String> {
537    let body = response
538        .into_body()
539        .read_to_string()
540        .map_err(|err| DomainError::io("reading backend response", IoError::other(err)))?;
541    Ok(body)
542}
543
544fn parse_task_response<T: DeserializeOwned>(
545    response: ureq::http::Response<ureq::Body>,
546) -> TaskMutationServiceResult<T> {
547    let status = response.status().as_u16();
548    let body = response
549        .into_body()
550        .read_to_string()
551        .map_err(|err| TaskMutationError::io("reading backend response", IoError::other(err)))?;
552    if !(200..300).contains(&status) {
553        return Err(map_status_to_task_error(status, &body));
554    }
555    serde_json::from_str(&body)
556        .map_err(|err| TaskMutationError::other(format!("Failed to parse backend response: {err}")))
557}
558
559fn parse_backend_response<T: DeserializeOwned>(
560    response: ureq::http::Response<ureq::Body>,
561) -> Result<T, ito_domain::backend::BackendError> {
562    let status = response.status().as_u16();
563    let body = response
564        .into_body()
565        .read_to_string()
566        .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
567    if !(200..300).contains(&status) {
568        return Err(map_status_to_backend_error(status, &body));
569    }
570    serde_json::from_str(&body)
571        .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))
572}
573
574fn map_status_to_domain_error(
575    status: u16,
576    entity: &'static str,
577    id: Option<&str>,
578    body: &str,
579) -> DomainError {
580    if status == 404 {
581        return DomainError::not_found(entity, id.unwrap_or("unknown"));
582    }
583
584    let kind = if status == 401 || status == 403 {
585        ErrorKind::PermissionDenied
586    } else if status >= 500 {
587        ErrorKind::Other
588    } else {
589        ErrorKind::InvalidData
590    };
591
592    let msg = if body.trim().is_empty() {
593        format!("backend returned HTTP {status}")
594    } else {
595        format!("backend returned HTTP {status}: {body}")
596    };
597    DomainError::io("backend request", IoError::new(kind, msg))
598}
599
600fn map_status_to_task_error(status: u16, body: &str) -> TaskMutationError {
601    if let Ok(api_error) = serde_json::from_str::<ApiErrorBody>(body) {
602        return match api_error.code.as_str() {
603            "not_found" => TaskMutationError::not_found(api_error.error),
604            "bad_request" => TaskMutationError::validation(api_error.error),
605            _ => TaskMutationError::other(api_error.error),
606        };
607    }
608
609    let message = if body.trim().is_empty() {
610        format!("backend returned HTTP {status}")
611    } else {
612        format!("backend returned HTTP {status}: {body}")
613    };
614    match status {
615        404 => TaskMutationError::not_found(message),
616        400..=499 => TaskMutationError::validation(message),
617        _ => TaskMutationError::other(message),
618    }
619}
620
621fn map_status_to_backend_error(status: u16, body: &str) -> ito_domain::backend::BackendError {
622    let message = if body.trim().is_empty() {
623        format!("backend returned HTTP {status}")
624    } else {
625        body.to_string()
626    };
627    match status {
628        401 | 403 => ito_domain::backend::BackendError::Unauthorized(message),
629        404 => ito_domain::backend::BackendError::NotFound(message),
630        409 => ito_domain::backend::BackendError::Other(message),
631        500..=599 => ito_domain::backend::BackendError::Unavailable(message),
632        _ => ito_domain::backend::BackendError::Other(message),
633    }
634}
635
636fn task_error_from_domain(err: DomainError) -> TaskMutationError {
637    match err {
638        DomainError::Io { context, source } => TaskMutationError::io(context, source),
639        DomainError::NotFound { entity, id } => {
640            TaskMutationError::not_found(format!("{entity} not found: {id}"))
641        }
642        DomainError::AmbiguousTarget {
643            entity,
644            input,
645            matches,
646        } => TaskMutationError::validation(format!(
647            "Ambiguous {entity} target '{input}'. Matches: {matches}"
648        )),
649    }
650}
651
652fn backend_error_from_domain(err: DomainError) -> ito_domain::backend::BackendError {
653    match err {
654        DomainError::Io { source, .. } => {
655            ito_domain::backend::BackendError::Other(source.to_string())
656        }
657        DomainError::NotFound { entity, id } => {
658            ito_domain::backend::BackendError::NotFound(format!("{entity} not found: {id}"))
659        }
660        DomainError::AmbiguousTarget {
661            entity,
662            input,
663            matches,
664        } => ito_domain::backend::BackendError::Other(format!(
665            "Ambiguous {entity} target '{input}'. Matches: {matches}"
666        )),
667    }
668}
669
670fn parse_timestamp(raw: &str) -> DomainResult<DateTime<Utc>> {
671    DateTime::parse_from_rfc3339(raw)
672        .map(|dt| dt.with_timezone(&Utc))
673        .map_err(|err| DomainError::io("parsing backend timestamp", IoError::other(err)))
674}
675
676fn sleep_backoff(attempt: u32) {
677    let delay_ms = 150u64.saturating_mul(attempt as u64);
678    std::thread::sleep(Duration::from_millis(delay_ms));
679}
680
681/// Converts a backend `ApiTaskList` into a `TasksParseResult`, mapping task fields,
682/// inferring the tasks format, building progress and wave lists, and emitting a
683/// diagnostic when enhanced-format tasks are missing dependency information.
684///
685/// The function:
686/// - interprets `list.format` ("enhanced" or "checkbox", falling back to checkbox on unknown),
687/// - converts each `ApiTaskItem` into a `TaskItem` (missing dependencies produce an empty list and a diagnostic),
688/// - constructs `ProgressInfo` from `list.progress`,
689/// - builds `WaveInfo` entries only for the enhanced format from distinct task wave values.
690///
691/// # Examples
692///
693/// ```ignore
694/// let api = ApiTaskList {
695///     format: "checkbox".to_string(),
696///     tasks: vec![],
697///     progress: ApiProgress {
698///         total: 0,
699///         complete: 0,
700///         shelved: 0,
701///         in_progress: 0,
702///         pending: 0,
703///         remaining: 0,
704///     },
705/// };
706/// let result = task_list_to_parse_result(api);
707/// assert_eq!(result.format, TasksFormat::Checkbox);
708/// assert!(result.tasks.is_empty());
709/// assert_eq!(result.progress.total, 0);
710/// ```
711fn task_list_to_parse_result(list: ApiTaskList) -> TasksParseResult {
712    let format = match list.format.as_str() {
713        "enhanced" => TasksFormat::Enhanced,
714        "checkbox" => TasksFormat::Checkbox,
715        other => {
716            tracing::warn!(
717                format = %other,
718                "unknown backend task format; falling back to checkbox parsing"
719            );
720            TasksFormat::Checkbox
721        }
722    };
723
724    let mut tasks = Vec::with_capacity(list.tasks.len());
725    let mut missing_dependencies = false;
726    for item in list.tasks {
727        let status = TaskStatus::from_enhanced_label(&item.status).unwrap_or(TaskStatus::Pending);
728        let dependencies = match item.dependencies {
729            Some(deps) => deps,
730            None => {
731                missing_dependencies = true;
732                Vec::new()
733            }
734        };
735        tasks.push(TaskItem {
736            id: item.id,
737            name: item.name,
738            wave: item.wave,
739            status,
740            updated_at: None,
741            dependencies,
742            files: Vec::new(),
743            action: String::new(),
744            verify: None,
745            done_when: None,
746            kind: TaskKind::Normal,
747            header_line_index: 0,
748            requirements: item.requirements,
749        });
750    }
751
752    let progress = ProgressInfo {
753        total: list.progress.total,
754        complete: list.progress.complete,
755        shelved: list.progress.shelved,
756        in_progress: list.progress.in_progress,
757        pending: list.progress.pending,
758        remaining: list.progress.remaining,
759    };
760
761    let waves = if format == TasksFormat::Enhanced {
762        let mut unique = BTreeSet::new();
763        for task in &tasks {
764            if let Some(wave) = task.wave {
765                unique.insert(wave);
766            }
767        }
768        unique
769            .into_iter()
770            .map(|wave| WaveInfo {
771                wave,
772                depends_on: Vec::new(),
773                header_line_index: 0,
774                depends_on_line_index: None,
775            })
776            .collect()
777    } else {
778        Vec::new()
779    };
780
781    let mut diagnostics = Vec::new();
782    if missing_dependencies && format == TasksFormat::Enhanced {
783        diagnostics.push(TaskDiagnostic {
784            level: DiagnosticLevel::Warning,
785            message: "Backend task payload missing dependencies; readiness may be inaccurate"
786                .to_string(),
787            task_id: None,
788            line: None,
789        });
790    }
791
792    TasksParseResult {
793        format,
794        tasks,
795        waves,
796        diagnostics,
797        progress,
798    }
799}
800
801fn tasks_from_progress(progress: &ApiProgress) -> TasksParseResult {
802    TasksParseResult {
803        format: TasksFormat::Checkbox,
804        tasks: Vec::new(),
805        waves: Vec::new(),
806        diagnostics: Vec::new(),
807        progress: ProgressInfo {
808            total: progress.total,
809            complete: progress.complete,
810            shelved: progress.shelved,
811            in_progress: progress.in_progress,
812            pending: progress.pending,
813            remaining: progress.remaining,
814        },
815    }
816}
817
818/// Converts an `ApiTaskMutationEnvelope` (backend response) into a domain `TaskMutationResult`.
819///
820/// The returned `TaskMutationResult` contains the mapped `TaskItem` (with status, kind,
821/// dependencies, files, action, verify, done_when, and an empty `requirements` list),
822/// the `change_id`, and the `revision`.
823///
824/// # Returns
825///
826/// `TaskMutationResult` containing the converted task and associated revision.
827///
828/// # Examples
829///
830/// ```ignore
831/// // given `response: ApiTaskMutationEnvelope` obtained from the backend
832/// let result = task_mutation_from_api(response);
833/// assert_eq!(result.change_id, response.change_id);
834/// assert_eq!(result.revision, response.revision);
835/// ```
836fn task_mutation_from_api(response: ApiTaskMutationEnvelope) -> TaskMutationResult {
837    TaskMutationResult {
838        change_id: response.change_id,
839        task: TaskItem {
840            id: response.task.id,
841            name: response.task.name,
842            wave: response.task.wave,
843            status: TaskStatus::from_enhanced_label(&response.task.status)
844                .unwrap_or(TaskStatus::Pending),
845            updated_at: response.task.updated_at,
846            dependencies: response.task.dependencies,
847            files: response.task.files,
848            action: response.task.action,
849            verify: response.task.verify,
850            done_when: response.task.done_when,
851            kind: match response.task.kind.as_str() {
852                "checkpoint" => TaskKind::Checkpoint,
853                _ => TaskKind::Normal,
854            },
855            header_line_index: response.task.header_line_index,
856            requirements: response.task.requirements,
857        },
858        revision: response.revision,
859    }
860}
861
862#[derive(Debug, Deserialize)]
863struct ApiChangeSummary {
864    id: String,
865    module_id: Option<String>,
866    completed_tasks: u32,
867    shelved_tasks: u32,
868    in_progress_tasks: u32,
869    pending_tasks: u32,
870    total_tasks: u32,
871    has_proposal: bool,
872    has_design: bool,
873    has_specs: bool,
874    has_tasks: bool,
875    #[allow(dead_code)]
876    work_status: String,
877    last_modified: String,
878}
879
880#[derive(Debug, Deserialize)]
881struct ApiChange {
882    id: String,
883    module_id: Option<String>,
884    proposal: Option<String>,
885    design: Option<String>,
886    specs: Vec<ApiSpec>,
887    progress: ApiProgress,
888    last_modified: String,
889}
890
891#[derive(Debug, Deserialize)]
892struct ApiSpec {
893    name: String,
894    content: String,
895}
896
897#[derive(Debug, Deserialize)]
898struct ApiProgress {
899    total: usize,
900    complete: usize,
901    shelved: usize,
902    in_progress: usize,
903    pending: usize,
904    remaining: usize,
905}
906
907#[derive(Debug, Deserialize)]
908struct ApiTaskList {
909    #[allow(dead_code)]
910    change_id: String,
911    tasks: Vec<ApiTaskItem>,
912    progress: ApiProgress,
913    format: String,
914}
915
916#[derive(Debug, Deserialize)]
917struct ApiTaskItem {
918    id: String,
919    name: String,
920    wave: Option<u32>,
921    status: String,
922    #[serde(default)]
923    dependencies: Option<Vec<String>>,
924    #[serde(default)]
925    requirements: Vec<String>,
926}
927
928#[derive(Debug, Deserialize)]
929struct ApiTaskMarkdown {
930    #[allow(dead_code)]
931    change_id: String,
932    content: Option<String>,
933}
934
935#[derive(Debug, Deserialize)]
936struct ApiTaskInitResult {
937    change_id: String,
938    path: Option<String>,
939    existed: bool,
940    revision: Option<String>,
941}
942
943#[derive(Debug, Deserialize)]
944struct ApiTaskMutationEnvelope {
945    change_id: String,
946    task: ApiTaskDetail,
947    revision: Option<String>,
948}
949
950#[derive(Debug, Deserialize)]
951struct ApiTaskDetail {
952    id: String,
953    name: String,
954    wave: Option<u32>,
955    status: String,
956    updated_at: Option<String>,
957    dependencies: Vec<String>,
958    files: Vec<String>,
959    action: String,
960    verify: Option<String>,
961    done_when: Option<String>,
962    kind: String,
963    header_line_index: usize,
964    #[serde(default)]
965    requirements: Vec<String>,
966}
967
968#[derive(Debug, Deserialize)]
969struct ApiSpecSummary {
970    id: String,
971    path: String,
972    last_modified: String,
973}
974
975#[derive(Debug, Deserialize)]
976struct ApiSpecDocument {
977    id: String,
978    path: String,
979    markdown: String,
980    last_modified: String,
981}
982
983#[derive(Debug, Deserialize)]
984struct ApiSubModuleSummary {
985    id: String,
986    name: String,
987    change_count: u32,
988}
989
990#[derive(Debug, Deserialize)]
991struct ApiSubModule {
992    id: String,
993    name: String,
994    description: Option<String>,
995    change_count: u32,
996}
997
998#[derive(Debug, Deserialize)]
999struct ApiModuleSummary {
1000    id: String,
1001    name: String,
1002    change_count: u32,
1003    #[serde(default)]
1004    sub_modules: Vec<ApiSubModuleSummary>,
1005}
1006
1007#[derive(Debug, Deserialize)]
1008struct ApiModule {
1009    id: String,
1010    name: String,
1011    description: Option<String>,
1012    #[serde(default)]
1013    sub_modules: Vec<ApiSubModule>,
1014}
1015
1016#[derive(Debug, Deserialize)]
1017struct ApiErrorBody {
1018    error: String,
1019    code: String,
1020}
1021
1022#[cfg(test)]
1023mod backend_http_tests;