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