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
681fn task_list_to_parse_result(list: ApiTaskList) -> TasksParseResult {
682    let format = match list.format.as_str() {
683        "enhanced" => TasksFormat::Enhanced,
684        "checkbox" => TasksFormat::Checkbox,
685        other => {
686            tracing::warn!(
687                format = %other,
688                "unknown backend task format; falling back to checkbox parsing"
689            );
690            TasksFormat::Checkbox
691        }
692    };
693
694    let mut tasks = Vec::with_capacity(list.tasks.len());
695    let mut missing_dependencies = false;
696    for item in list.tasks {
697        let status = TaskStatus::from_enhanced_label(&item.status).unwrap_or(TaskStatus::Pending);
698        let dependencies = match item.dependencies {
699            Some(deps) => deps,
700            None => {
701                missing_dependencies = true;
702                Vec::new()
703            }
704        };
705        tasks.push(TaskItem {
706            id: item.id,
707            name: item.name,
708            wave: item.wave,
709            status,
710            updated_at: None,
711            dependencies,
712            files: Vec::new(),
713            action: String::new(),
714            verify: None,
715            done_when: None,
716            kind: TaskKind::Normal,
717            header_line_index: 0,
718        });
719    }
720
721    let progress = ProgressInfo {
722        total: list.progress.total,
723        complete: list.progress.complete,
724        shelved: list.progress.shelved,
725        in_progress: list.progress.in_progress,
726        pending: list.progress.pending,
727        remaining: list.progress.remaining,
728    };
729
730    let waves = if format == TasksFormat::Enhanced {
731        let mut unique = BTreeSet::new();
732        for task in &tasks {
733            if let Some(wave) = task.wave {
734                unique.insert(wave);
735            }
736        }
737        unique
738            .into_iter()
739            .map(|wave| WaveInfo {
740                wave,
741                depends_on: Vec::new(),
742                header_line_index: 0,
743                depends_on_line_index: None,
744            })
745            .collect()
746    } else {
747        Vec::new()
748    };
749
750    let mut diagnostics = Vec::new();
751    if missing_dependencies && format == TasksFormat::Enhanced {
752        diagnostics.push(TaskDiagnostic {
753            level: DiagnosticLevel::Warning,
754            message: "Backend task payload missing dependencies; readiness may be inaccurate"
755                .to_string(),
756            task_id: None,
757            line: None,
758        });
759    }
760
761    TasksParseResult {
762        format,
763        tasks,
764        waves,
765        diagnostics,
766        progress,
767    }
768}
769
770fn tasks_from_progress(progress: &ApiProgress) -> TasksParseResult {
771    TasksParseResult {
772        format: TasksFormat::Checkbox,
773        tasks: Vec::new(),
774        waves: Vec::new(),
775        diagnostics: Vec::new(),
776        progress: ProgressInfo {
777            total: progress.total,
778            complete: progress.complete,
779            shelved: progress.shelved,
780            in_progress: progress.in_progress,
781            pending: progress.pending,
782            remaining: progress.remaining,
783        },
784    }
785}
786
787fn task_mutation_from_api(response: ApiTaskMutationEnvelope) -> TaskMutationResult {
788    TaskMutationResult {
789        change_id: response.change_id,
790        task: TaskItem {
791            id: response.task.id,
792            name: response.task.name,
793            wave: response.task.wave,
794            status: TaskStatus::from_enhanced_label(&response.task.status)
795                .unwrap_or(TaskStatus::Pending),
796            updated_at: response.task.updated_at,
797            dependencies: response.task.dependencies,
798            files: response.task.files,
799            action: response.task.action,
800            verify: response.task.verify,
801            done_when: response.task.done_when,
802            kind: match response.task.kind.as_str() {
803                "checkpoint" => TaskKind::Checkpoint,
804                _ => TaskKind::Normal,
805            },
806            header_line_index: response.task.header_line_index,
807        },
808        revision: response.revision,
809    }
810}
811
812#[derive(Debug, Deserialize)]
813struct ApiChangeSummary {
814    id: String,
815    module_id: Option<String>,
816    completed_tasks: u32,
817    shelved_tasks: u32,
818    in_progress_tasks: u32,
819    pending_tasks: u32,
820    total_tasks: u32,
821    has_proposal: bool,
822    has_design: bool,
823    has_specs: bool,
824    has_tasks: bool,
825    #[allow(dead_code)]
826    work_status: String,
827    last_modified: String,
828}
829
830#[derive(Debug, Deserialize)]
831struct ApiChange {
832    id: String,
833    module_id: Option<String>,
834    proposal: Option<String>,
835    design: Option<String>,
836    specs: Vec<ApiSpec>,
837    progress: ApiProgress,
838    last_modified: String,
839}
840
841#[derive(Debug, Deserialize)]
842struct ApiSpec {
843    name: String,
844    content: String,
845}
846
847#[derive(Debug, Deserialize)]
848struct ApiProgress {
849    total: usize,
850    complete: usize,
851    shelved: usize,
852    in_progress: usize,
853    pending: usize,
854    remaining: usize,
855}
856
857#[derive(Debug, Deserialize)]
858struct ApiTaskList {
859    #[allow(dead_code)]
860    change_id: String,
861    tasks: Vec<ApiTaskItem>,
862    progress: ApiProgress,
863    format: String,
864}
865
866#[derive(Debug, Deserialize)]
867struct ApiTaskItem {
868    id: String,
869    name: String,
870    wave: Option<u32>,
871    status: String,
872    #[serde(default)]
873    dependencies: Option<Vec<String>>,
874}
875
876#[derive(Debug, Deserialize)]
877struct ApiTaskMarkdown {
878    #[allow(dead_code)]
879    change_id: String,
880    content: Option<String>,
881}
882
883#[derive(Debug, Deserialize)]
884struct ApiTaskInitResult {
885    change_id: String,
886    path: Option<String>,
887    existed: bool,
888    revision: Option<String>,
889}
890
891#[derive(Debug, Deserialize)]
892struct ApiTaskMutationEnvelope {
893    change_id: String,
894    task: ApiTaskDetail,
895    revision: Option<String>,
896}
897
898#[derive(Debug, Deserialize)]
899struct ApiTaskDetail {
900    id: String,
901    name: String,
902    wave: Option<u32>,
903    status: String,
904    updated_at: Option<String>,
905    dependencies: Vec<String>,
906    files: Vec<String>,
907    action: String,
908    verify: Option<String>,
909    done_when: Option<String>,
910    kind: String,
911    header_line_index: usize,
912}
913
914#[derive(Debug, Deserialize)]
915struct ApiSpecSummary {
916    id: String,
917    path: String,
918    last_modified: String,
919}
920
921#[derive(Debug, Deserialize)]
922struct ApiSpecDocument {
923    id: String,
924    path: String,
925    markdown: String,
926    last_modified: String,
927}
928
929#[derive(Debug, Deserialize)]
930struct ApiSubModuleSummary {
931    id: String,
932    name: String,
933    change_count: u32,
934}
935
936#[derive(Debug, Deserialize)]
937struct ApiSubModule {
938    id: String,
939    name: String,
940    description: Option<String>,
941    change_count: u32,
942}
943
944#[derive(Debug, Deserialize)]
945struct ApiModuleSummary {
946    id: String,
947    name: String,
948    change_count: u32,
949    #[serde(default)]
950    sub_modules: Vec<ApiSubModuleSummary>,
951}
952
953#[derive(Debug, Deserialize)]
954struct ApiModule {
955    id: String,
956    name: String,
957    description: Option<String>,
958    #[serde(default)]
959    sub_modules: Vec<ApiSubModule>,
960}
961
962#[derive(Debug, Deserialize)]
963struct ApiErrorBody {
964    error: String,
965    code: String,
966}
967
968#[cfg(test)]
969mod backend_http_tests;