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