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