1use 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#[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 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 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 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 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;