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                orchestrate: ito_domain::changes::ChangeOrchestrateMetadata::default(),
265            });
266        }
267        Ok(out)
268    }
269
270    fn get_change(&self, change_id: &str, filter: ChangeLifecycleFilter) -> DomainResult<Change> {
271        let url = format!(
272            "{}/changes/{change_id}?lifecycle={}",
273            self.inner.runtime.project_api_prefix(),
274            filter.as_str()
275        );
276        let change: ApiChange = self.get_json(&url, "change", Some(change_id))?;
277        let tasks = match self.load_tasks_parse_result(change_id) {
278            Ok(tasks) => tasks,
279            Err(err) => {
280                if filter.includes_archived() && is_not_found_error(&err) {
281                    tasks_from_progress(&change.progress)
282                } else {
283                    return Err(err);
284                }
285            }
286        };
287        let last_modified = parse_timestamp(&change.last_modified)?;
288        let sub_module_id = extract_sub_module_id(&change.id);
289        Ok(Change {
290            id: change.id,
291            module_id: change.module_id,
292            sub_module_id,
293            path: PathBuf::new(),
294            proposal: change.proposal,
295            design: change.design,
296            specs: {
297                let mut specs = Vec::with_capacity(change.specs.len());
298                for spec in change.specs {
299                    specs.push(Spec {
300                        name: spec.name,
301                        content: spec.content,
302                    });
303                }
304                specs
305            },
306            tasks,
307            orchestrate: ito_domain::changes::ChangeOrchestrateMetadata::default(),
308            last_modified,
309        })
310    }
311}
312
313impl BackendModuleReader for BackendHttpClient {
314    fn list_modules(&self) -> DomainResult<Vec<ModuleSummary>> {
315        let url = format!("{}/modules", self.inner.runtime.project_api_prefix());
316        let modules: Vec<ApiModuleSummary> = self.get_json(&url, "module", None)?;
317        let mut out = Vec::with_capacity(modules.len());
318        for m in modules {
319            let mut sub_modules = Vec::with_capacity(m.sub_modules.len());
320            for s in m.sub_modules {
321                sub_modules.push(SubModuleSummary {
322                    id: s.id,
323                    name: s.name,
324                    change_count: s.change_count,
325                });
326            }
327            out.push(ModuleSummary {
328                id: m.id,
329                name: m.name,
330                change_count: m.change_count,
331                sub_modules,
332            });
333        }
334        Ok(out)
335    }
336
337    fn get_module(&self, module_id: &str) -> DomainResult<Module> {
338        let url = format!(
339            "{}/modules/{module_id}",
340            self.inner.runtime.project_api_prefix()
341        );
342        let module: ApiModule = self.get_json(&url, "module", Some(module_id))?;
343        let mut sub_modules = Vec::with_capacity(module.sub_modules.len());
344        for s in module.sub_modules {
345            let sub_id = s.id.split('.').nth(1).unwrap_or("").to_string();
346            sub_modules.push(SubModule {
347                id: s.id,
348                parent_module_id: module.id.clone(),
349                sub_id,
350                name: s.name,
351                description: s.description,
352                change_count: s.change_count,
353                path: PathBuf::new(),
354            });
355        }
356        Ok(Module {
357            id: module.id,
358            name: module.name,
359            description: module.description,
360            path: PathBuf::new(),
361            sub_modules,
362        })
363    }
364}
365
366impl BackendSpecReader for BackendHttpClient {
367    fn list_specs(&self) -> DomainResult<Vec<SpecSummary>> {
368        let url = format!("{}/specs", self.inner.runtime.project_api_prefix());
369        let specs: Vec<ApiSpecSummary> = self.get_json(&url, "spec", None)?;
370        let mut out = Vec::with_capacity(specs.len());
371        for spec in specs {
372            out.push(SpecSummary {
373                id: spec.id,
374                path: PathBuf::from(spec.path),
375                last_modified: parse_timestamp(&spec.last_modified)?,
376            });
377        }
378        Ok(out)
379    }
380
381    fn get_spec(&self, spec_id: &str) -> DomainResult<SpecDocument> {
382        let url = format!(
383            "{}/specs/{spec_id}",
384            self.inner.runtime.project_api_prefix()
385        );
386        let spec: ApiSpecDocument = self.get_json(&url, "spec", Some(spec_id))?;
387        Ok(SpecDocument {
388            id: spec.id,
389            path: PathBuf::from(spec.path),
390            markdown: spec.markdown,
391            last_modified: parse_timestamp(&spec.last_modified)?,
392        })
393    }
394}
395
396impl TaskMutationService for BackendHttpClient {
397    fn load_tasks_markdown(&self, change_id: &str) -> TaskMutationServiceResult<Option<String>> {
398        let url = format!(
399            "{}/changes/{change_id}/tasks/raw",
400            self.inner.runtime.project_api_prefix()
401        );
402        let response: ApiTaskMarkdown = self.task_get_json(&url)?;
403        Ok(response.content)
404    }
405
406    fn init_tasks(&self, change_id: &str) -> TaskMutationServiceResult<TaskInitResult> {
407        let url = format!(
408            "{}/changes/{change_id}/tasks/init",
409            self.inner.runtime.project_api_prefix()
410        );
411        let response: ApiTaskInitResult = self.task_post_json(&url, Some("{}"))?;
412        Ok(TaskInitResult {
413            change_id: response.change_id,
414            path: response.path.map(PathBuf::from),
415            existed: response.existed,
416            revision: response.revision,
417        })
418    }
419
420    fn start_task(
421        &self,
422        change_id: &str,
423        task_id: &str,
424    ) -> TaskMutationServiceResult<TaskMutationResult> {
425        let url = format!(
426            "{}/changes/{change_id}/tasks/{task_id}/start",
427            self.inner.runtime.project_api_prefix()
428        );
429        let response: ApiTaskMutationEnvelope = self.task_post_json(&url, Some("{}"))?;
430        Ok(task_mutation_from_api(response))
431    }
432
433    fn complete_task(
434        &self,
435        change_id: &str,
436        task_id: &str,
437        note: Option<String>,
438    ) -> TaskMutationServiceResult<TaskMutationResult> {
439        let url = format!(
440            "{}/changes/{change_id}/tasks/{task_id}/complete",
441            self.inner.runtime.project_api_prefix()
442        );
443        let body = optional_task_text_body("note", note);
444        let response: ApiTaskMutationEnvelope = self.task_post_json(&url, Some(&body))?;
445        Ok(task_mutation_from_api(response))
446    }
447
448    fn shelve_task(
449        &self,
450        change_id: &str,
451        task_id: &str,
452        reason: Option<String>,
453    ) -> TaskMutationServiceResult<TaskMutationResult> {
454        let url = format!(
455            "{}/changes/{change_id}/tasks/{task_id}/shelve",
456            self.inner.runtime.project_api_prefix()
457        );
458        let body = optional_task_text_body("reason", reason);
459        let response: ApiTaskMutationEnvelope = self.task_post_json(&url, Some(&body))?;
460        Ok(task_mutation_from_api(response))
461    }
462
463    fn unshelve_task(
464        &self,
465        change_id: &str,
466        task_id: &str,
467    ) -> TaskMutationServiceResult<TaskMutationResult> {
468        let url = format!(
469            "{}/changes/{change_id}/tasks/{task_id}/unshelve",
470            self.inner.runtime.project_api_prefix()
471        );
472        let response: ApiTaskMutationEnvelope = self.task_post_json(&url, Some("{}"))?;
473        Ok(task_mutation_from_api(response))
474    }
475
476    fn add_task(
477        &self,
478        change_id: &str,
479        title: &str,
480        wave: Option<u32>,
481    ) -> TaskMutationServiceResult<TaskMutationResult> {
482        let url = format!(
483            "{}/changes/{change_id}/tasks/add",
484            self.inner.runtime.project_api_prefix()
485        );
486        let body = serde_json::json!({ "title": title, "wave": wave }).to_string();
487        let response: ApiTaskMutationEnvelope = self.task_post_json(&url, Some(&body))?;
488        Ok(task_mutation_from_api(response))
489    }
490}
491
492impl BackendSyncClient for BackendHttpClient {
493    fn pull(&self, change_id: &str) -> Result<ArtifactBundle, ito_domain::backend::BackendError> {
494        let url = format!(
495            "{}/changes/{change_id}/sync",
496            self.inner.runtime.project_api_prefix()
497        );
498        self.backend_get_json(&url)
499    }
500
501    fn push(
502        &self,
503        change_id: &str,
504        bundle: &ArtifactBundle,
505    ) -> Result<PushResult, ito_domain::backend::BackendError> {
506        let url = format!(
507            "{}/changes/{change_id}/sync",
508            self.inner.runtime.project_api_prefix()
509        );
510        let body = serde_json::to_string(bundle)
511            .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
512        self.backend_post_json(&url, Some(&body), false)
513    }
514}
515
516impl BackendArchiveClient for BackendHttpClient {
517    fn mark_archived(
518        &self,
519        change_id: &str,
520    ) -> Result<ArchiveResult, ito_domain::backend::BackendError> {
521        let url = format!(
522            "{}/changes/{change_id}/archive",
523            self.inner.runtime.project_api_prefix()
524        );
525        self.backend_post_json(&url, Some("{}"), false)
526    }
527}
528
529impl BackendEventIngestClient for BackendHttpClient {
530    fn ingest(
531        &self,
532        batch: &EventBatch,
533    ) -> Result<EventIngestResult, ito_domain::backend::BackendError> {
534        self.ingest_event_batch(batch)
535    }
536}
537
538fn read_response_body(response: ureq::http::Response<ureq::Body>) -> DomainResult<String> {
539    let body = response
540        .into_body()
541        .read_to_string()
542        .map_err(|err| DomainError::io("reading backend response", IoError::other(err)))?;
543    Ok(body)
544}
545
546fn parse_task_response<T: DeserializeOwned>(
547    response: ureq::http::Response<ureq::Body>,
548) -> TaskMutationServiceResult<T> {
549    let status = response.status().as_u16();
550    let body = response
551        .into_body()
552        .read_to_string()
553        .map_err(|err| TaskMutationError::io("reading backend response", IoError::other(err)))?;
554    if !(200..300).contains(&status) {
555        return Err(map_status_to_task_error(status, &body));
556    }
557    serde_json::from_str(&body)
558        .map_err(|err| TaskMutationError::other(format!("Failed to parse backend response: {err}")))
559}
560
561fn parse_backend_response<T: DeserializeOwned>(
562    response: ureq::http::Response<ureq::Body>,
563) -> Result<T, ito_domain::backend::BackendError> {
564    let status = response.status().as_u16();
565    let body = response
566        .into_body()
567        .read_to_string()
568        .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
569    if !(200..300).contains(&status) {
570        return Err(map_status_to_backend_error(status, &body));
571    }
572    serde_json::from_str(&body)
573        .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))
574}
575
576fn map_status_to_domain_error(
577    status: u16,
578    entity: &'static str,
579    id: Option<&str>,
580    body: &str,
581) -> DomainError {
582    if status == 404 {
583        return DomainError::not_found(entity, id.unwrap_or("unknown"));
584    }
585
586    let kind = if status == 401 || status == 403 {
587        ErrorKind::PermissionDenied
588    } else if status >= 500 {
589        ErrorKind::Other
590    } else {
591        ErrorKind::InvalidData
592    };
593
594    let msg = if body.trim().is_empty() {
595        format!("backend returned HTTP {status}")
596    } else {
597        format!("backend returned HTTP {status}: {body}")
598    };
599    DomainError::io("backend request", IoError::new(kind, msg))
600}
601
602fn map_status_to_task_error(status: u16, body: &str) -> TaskMutationError {
603    if let Ok(api_error) = serde_json::from_str::<ApiErrorBody>(body) {
604        return match api_error.code.as_str() {
605            "not_found" => TaskMutationError::not_found(api_error.error),
606            "bad_request" => TaskMutationError::validation(api_error.error),
607            _ => TaskMutationError::other(api_error.error),
608        };
609    }
610
611    let message = if body.trim().is_empty() {
612        format!("backend returned HTTP {status}")
613    } else {
614        format!("backend returned HTTP {status}: {body}")
615    };
616    match status {
617        404 => TaskMutationError::not_found(message),
618        400..=499 => TaskMutationError::validation(message),
619        _ => TaskMutationError::other(message),
620    }
621}
622
623fn map_status_to_backend_error(status: u16, body: &str) -> ito_domain::backend::BackendError {
624    let message = if body.trim().is_empty() {
625        format!("backend returned HTTP {status}")
626    } else {
627        body.to_string()
628    };
629    match status {
630        401 | 403 => ito_domain::backend::BackendError::Unauthorized(message),
631        404 => ito_domain::backend::BackendError::NotFound(message),
632        409 => ito_domain::backend::BackendError::Other(message),
633        500..=599 => ito_domain::backend::BackendError::Unavailable(message),
634        _ => ito_domain::backend::BackendError::Other(message),
635    }
636}
637
638fn task_error_from_domain(err: DomainError) -> TaskMutationError {
639    match err {
640        DomainError::Io { context, source } => TaskMutationError::io(context, source),
641        DomainError::NotFound { entity, id } => {
642            TaskMutationError::not_found(format!("{entity} not found: {id}"))
643        }
644        DomainError::AmbiguousTarget {
645            entity,
646            input,
647            matches,
648        } => TaskMutationError::validation(format!(
649            "Ambiguous {entity} target '{input}'. Matches: {matches}"
650        )),
651    }
652}
653
654fn backend_error_from_domain(err: DomainError) -> ito_domain::backend::BackendError {
655    match err {
656        DomainError::Io { source, .. } => {
657            ito_domain::backend::BackendError::Other(source.to_string())
658        }
659        DomainError::NotFound { entity, id } => {
660            ito_domain::backend::BackendError::NotFound(format!("{entity} not found: {id}"))
661        }
662        DomainError::AmbiguousTarget {
663            entity,
664            input,
665            matches,
666        } => ito_domain::backend::BackendError::Other(format!(
667            "Ambiguous {entity} target '{input}'. Matches: {matches}"
668        )),
669    }
670}
671
672fn parse_timestamp(raw: &str) -> DomainResult<DateTime<Utc>> {
673    DateTime::parse_from_rfc3339(raw)
674        .map(|dt| dt.with_timezone(&Utc))
675        .map_err(|err| DomainError::io("parsing backend timestamp", IoError::other(err)))
676}
677
678fn sleep_backoff(attempt: u32) {
679    let delay_ms = 150u64.saturating_mul(attempt as u64);
680    std::thread::sleep(Duration::from_millis(delay_ms));
681}
682
683/// Converts a backend `ApiTaskList` into a `TasksParseResult`, mapping task fields,
684/// inferring the tasks format, building progress and wave lists, and emitting a
685/// diagnostic when enhanced-format tasks are missing dependency information.
686///
687/// The function:
688/// - interprets `list.format` ("enhanced" or "checkbox", falling back to checkbox on unknown),
689/// - converts each `ApiTaskItem` into a `TaskItem` (missing dependencies produce an empty list and a diagnostic),
690/// - constructs `ProgressInfo` from `list.progress`,
691/// - builds `WaveInfo` entries only for the enhanced format from distinct task wave values.
692///
693/// # Examples
694///
695/// ```ignore
696/// let api = ApiTaskList {
697///     format: "checkbox".to_string(),
698///     tasks: vec![],
699///     progress: ApiProgress {
700///         total: 0,
701///         complete: 0,
702///         shelved: 0,
703///         in_progress: 0,
704///         pending: 0,
705///         remaining: 0,
706///     },
707/// };
708/// let result = task_list_to_parse_result(api);
709/// assert_eq!(result.format, TasksFormat::Checkbox);
710/// assert!(result.tasks.is_empty());
711/// assert_eq!(result.progress.total, 0);
712/// ```
713fn task_list_to_parse_result(list: ApiTaskList) -> TasksParseResult {
714    let format = match list.format.as_str() {
715        "enhanced" => TasksFormat::Enhanced,
716        "checkbox" => TasksFormat::Checkbox,
717        other => {
718            tracing::warn!(
719                format = %other,
720                "unknown backend task format; falling back to checkbox parsing"
721            );
722            TasksFormat::Checkbox
723        }
724    };
725
726    let mut tasks = Vec::with_capacity(list.tasks.len());
727    let mut missing_dependencies = false;
728    for item in list.tasks {
729        let status = TaskStatus::from_enhanced_label(&item.status).unwrap_or(TaskStatus::Pending);
730        let dependencies = match item.dependencies {
731            Some(deps) => deps,
732            None => {
733                missing_dependencies = true;
734                Vec::new()
735            }
736        };
737        tasks.push(TaskItem {
738            id: item.id,
739            name: item.name,
740            wave: item.wave,
741            status,
742            updated_at: None,
743            dependencies,
744            files: Vec::new(),
745            action: String::new(),
746            verify: None,
747            done_when: None,
748            kind: TaskKind::Normal,
749            header_line_index: 0,
750            requirements: item.requirements,
751        });
752    }
753
754    let progress = ProgressInfo {
755        total: list.progress.total,
756        complete: list.progress.complete,
757        shelved: list.progress.shelved,
758        in_progress: list.progress.in_progress,
759        pending: list.progress.pending,
760        remaining: list.progress.remaining,
761    };
762
763    let waves = if format == TasksFormat::Enhanced {
764        let mut unique = BTreeSet::new();
765        for task in &tasks {
766            if let Some(wave) = task.wave {
767                unique.insert(wave);
768            }
769        }
770        unique
771            .into_iter()
772            .map(|wave| WaveInfo {
773                wave,
774                depends_on: Vec::new(),
775                header_line_index: 0,
776                depends_on_line_index: None,
777            })
778            .collect()
779    } else {
780        Vec::new()
781    };
782
783    let mut diagnostics = Vec::new();
784    if missing_dependencies && format == TasksFormat::Enhanced {
785        diagnostics.push(TaskDiagnostic {
786            level: DiagnosticLevel::Warning,
787            message: "Backend task payload missing dependencies; readiness may be inaccurate"
788                .to_string(),
789            task_id: None,
790            line: None,
791        });
792    }
793
794    TasksParseResult {
795        format,
796        tasks,
797        waves,
798        diagnostics,
799        progress,
800    }
801}
802
803fn tasks_from_progress(progress: &ApiProgress) -> TasksParseResult {
804    TasksParseResult {
805        format: TasksFormat::Checkbox,
806        tasks: Vec::new(),
807        waves: Vec::new(),
808        diagnostics: Vec::new(),
809        progress: ProgressInfo {
810            total: progress.total,
811            complete: progress.complete,
812            shelved: progress.shelved,
813            in_progress: progress.in_progress,
814            pending: progress.pending,
815            remaining: progress.remaining,
816        },
817    }
818}
819
820/// Converts an `ApiTaskMutationEnvelope` (backend response) into a domain `TaskMutationResult`.
821///
822/// The returned `TaskMutationResult` contains the mapped `TaskItem` (with status, kind,
823/// dependencies, files, action, verify, done_when, and an empty `requirements` list),
824/// the `change_id`, and the `revision`.
825///
826/// # Returns
827///
828/// `TaskMutationResult` containing the converted task and associated revision.
829///
830/// # Examples
831///
832/// ```ignore
833/// // given `response: ApiTaskMutationEnvelope` obtained from the backend
834/// let result = task_mutation_from_api(response);
835/// assert_eq!(result.change_id, response.change_id);
836/// assert_eq!(result.revision, response.revision);
837/// ```
838fn task_mutation_from_api(response: ApiTaskMutationEnvelope) -> TaskMutationResult {
839    TaskMutationResult {
840        change_id: response.change_id,
841        task: TaskItem {
842            id: response.task.id,
843            name: response.task.name,
844            wave: response.task.wave,
845            status: TaskStatus::from_enhanced_label(&response.task.status)
846                .unwrap_or(TaskStatus::Pending),
847            updated_at: response.task.updated_at,
848            dependencies: response.task.dependencies,
849            files: response.task.files,
850            action: response.task.action,
851            verify: response.task.verify,
852            done_when: response.task.done_when,
853            kind: match response.task.kind.as_str() {
854                "checkpoint" => TaskKind::Checkpoint,
855                _ => TaskKind::Normal,
856            },
857            header_line_index: response.task.header_line_index,
858            requirements: response.task.requirements,
859        },
860        revision: response.revision,
861    }
862}
863
864#[derive(Debug, Deserialize)]
865struct ApiChangeSummary {
866    id: String,
867    module_id: Option<String>,
868    completed_tasks: u32,
869    shelved_tasks: u32,
870    in_progress_tasks: u32,
871    pending_tasks: u32,
872    total_tasks: u32,
873    has_proposal: bool,
874    has_design: bool,
875    has_specs: bool,
876    has_tasks: bool,
877    #[allow(dead_code)]
878    work_status: String,
879    last_modified: String,
880}
881
882#[derive(Debug, Deserialize)]
883struct ApiChange {
884    id: String,
885    module_id: Option<String>,
886    proposal: Option<String>,
887    design: Option<String>,
888    specs: Vec<ApiSpec>,
889    progress: ApiProgress,
890    last_modified: String,
891}
892
893#[derive(Debug, Deserialize)]
894struct ApiSpec {
895    name: String,
896    content: String,
897}
898
899#[derive(Debug, Deserialize)]
900struct ApiProgress {
901    total: usize,
902    complete: usize,
903    shelved: usize,
904    in_progress: usize,
905    pending: usize,
906    remaining: usize,
907}
908
909#[derive(Debug, Deserialize)]
910struct ApiTaskList {
911    #[allow(dead_code)]
912    change_id: String,
913    tasks: Vec<ApiTaskItem>,
914    progress: ApiProgress,
915    format: String,
916}
917
918#[derive(Debug, Deserialize)]
919struct ApiTaskItem {
920    id: String,
921    name: String,
922    wave: Option<u32>,
923    status: String,
924    #[serde(default)]
925    dependencies: Option<Vec<String>>,
926    #[serde(default)]
927    requirements: Vec<String>,
928}
929
930#[derive(Debug, Deserialize)]
931struct ApiTaskMarkdown {
932    #[allow(dead_code)]
933    change_id: String,
934    content: Option<String>,
935}
936
937#[derive(Debug, Deserialize)]
938struct ApiTaskInitResult {
939    change_id: String,
940    path: Option<String>,
941    existed: bool,
942    revision: Option<String>,
943}
944
945#[derive(Debug, Deserialize)]
946struct ApiTaskMutationEnvelope {
947    change_id: String,
948    task: ApiTaskDetail,
949    revision: Option<String>,
950}
951
952#[derive(Debug, Deserialize)]
953struct ApiTaskDetail {
954    id: String,
955    name: String,
956    wave: Option<u32>,
957    status: String,
958    updated_at: Option<String>,
959    dependencies: Vec<String>,
960    files: Vec<String>,
961    action: String,
962    verify: Option<String>,
963    done_when: Option<String>,
964    kind: String,
965    header_line_index: usize,
966    #[serde(default)]
967    requirements: Vec<String>,
968}
969
970#[derive(Debug, Deserialize)]
971struct ApiSpecSummary {
972    id: String,
973    path: String,
974    last_modified: String,
975}
976
977#[derive(Debug, Deserialize)]
978struct ApiSpecDocument {
979    id: String,
980    path: String,
981    markdown: String,
982    last_modified: String,
983}
984
985#[derive(Debug, Deserialize)]
986struct ApiSubModuleSummary {
987    id: String,
988    name: String,
989    change_count: u32,
990}
991
992#[derive(Debug, Deserialize)]
993struct ApiSubModule {
994    id: String,
995    name: String,
996    description: Option<String>,
997    change_count: u32,
998}
999
1000#[derive(Debug, Deserialize)]
1001struct ApiModuleSummary {
1002    id: String,
1003    name: String,
1004    change_count: u32,
1005    #[serde(default)]
1006    sub_modules: Vec<ApiSubModuleSummary>,
1007}
1008
1009#[derive(Debug, Deserialize)]
1010struct ApiModule {
1011    id: String,
1012    name: String,
1013    description: Option<String>,
1014    #[serde(default)]
1015    sub_modules: Vec<ApiSubModule>,
1016}
1017
1018#[derive(Debug, Deserialize)]
1019struct ApiErrorBody {
1020    error: String,
1021    code: String,
1022}
1023
1024#[cfg(test)]
1025mod backend_http_tests;