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