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 {
682 let format = match list.format.as_str() {
683 "enhanced" => TasksFormat::Enhanced,
684 "checkbox" => TasksFormat::Checkbox,
685 other => {
686 tracing::warn!(
687 format = %other,
688 "unknown backend task format; falling back to checkbox parsing"
689 );
690 TasksFormat::Checkbox
691 }
692 };
693
694 let mut tasks = Vec::with_capacity(list.tasks.len());
695 let mut missing_dependencies = false;
696 for item in list.tasks {
697 let status = TaskStatus::from_enhanced_label(&item.status).unwrap_or(TaskStatus::Pending);
698 let dependencies = match item.dependencies {
699 Some(deps) => deps,
700 None => {
701 missing_dependencies = true;
702 Vec::new()
703 }
704 };
705 tasks.push(TaskItem {
706 id: item.id,
707 name: item.name,
708 wave: item.wave,
709 status,
710 updated_at: None,
711 dependencies,
712 files: Vec::new(),
713 action: String::new(),
714 verify: None,
715 done_when: None,
716 kind: TaskKind::Normal,
717 header_line_index: 0,
718 });
719 }
720
721 let progress = ProgressInfo {
722 total: list.progress.total,
723 complete: list.progress.complete,
724 shelved: list.progress.shelved,
725 in_progress: list.progress.in_progress,
726 pending: list.progress.pending,
727 remaining: list.progress.remaining,
728 };
729
730 let waves = if format == TasksFormat::Enhanced {
731 let mut unique = BTreeSet::new();
732 for task in &tasks {
733 if let Some(wave) = task.wave {
734 unique.insert(wave);
735 }
736 }
737 unique
738 .into_iter()
739 .map(|wave| WaveInfo {
740 wave,
741 depends_on: Vec::new(),
742 header_line_index: 0,
743 depends_on_line_index: None,
744 })
745 .collect()
746 } else {
747 Vec::new()
748 };
749
750 let mut diagnostics = Vec::new();
751 if missing_dependencies && format == TasksFormat::Enhanced {
752 diagnostics.push(TaskDiagnostic {
753 level: DiagnosticLevel::Warning,
754 message: "Backend task payload missing dependencies; readiness may be inaccurate"
755 .to_string(),
756 task_id: None,
757 line: None,
758 });
759 }
760
761 TasksParseResult {
762 format,
763 tasks,
764 waves,
765 diagnostics,
766 progress,
767 }
768}
769
770fn tasks_from_progress(progress: &ApiProgress) -> TasksParseResult {
771 TasksParseResult {
772 format: TasksFormat::Checkbox,
773 tasks: Vec::new(),
774 waves: Vec::new(),
775 diagnostics: Vec::new(),
776 progress: ProgressInfo {
777 total: progress.total,
778 complete: progress.complete,
779 shelved: progress.shelved,
780 in_progress: progress.in_progress,
781 pending: progress.pending,
782 remaining: progress.remaining,
783 },
784 }
785}
786
787fn task_mutation_from_api(response: ApiTaskMutationEnvelope) -> TaskMutationResult {
788 TaskMutationResult {
789 change_id: response.change_id,
790 task: TaskItem {
791 id: response.task.id,
792 name: response.task.name,
793 wave: response.task.wave,
794 status: TaskStatus::from_enhanced_label(&response.task.status)
795 .unwrap_or(TaskStatus::Pending),
796 updated_at: response.task.updated_at,
797 dependencies: response.task.dependencies,
798 files: response.task.files,
799 action: response.task.action,
800 verify: response.task.verify,
801 done_when: response.task.done_when,
802 kind: match response.task.kind.as_str() {
803 "checkpoint" => TaskKind::Checkpoint,
804 _ => TaskKind::Normal,
805 },
806 header_line_index: response.task.header_line_index,
807 },
808 revision: response.revision,
809 }
810}
811
812#[derive(Debug, Deserialize)]
813struct ApiChangeSummary {
814 id: String,
815 module_id: Option<String>,
816 completed_tasks: u32,
817 shelved_tasks: u32,
818 in_progress_tasks: u32,
819 pending_tasks: u32,
820 total_tasks: u32,
821 has_proposal: bool,
822 has_design: bool,
823 has_specs: bool,
824 has_tasks: bool,
825 #[allow(dead_code)]
826 work_status: String,
827 last_modified: String,
828}
829
830#[derive(Debug, Deserialize)]
831struct ApiChange {
832 id: String,
833 module_id: Option<String>,
834 proposal: Option<String>,
835 design: Option<String>,
836 specs: Vec<ApiSpec>,
837 progress: ApiProgress,
838 last_modified: String,
839}
840
841#[derive(Debug, Deserialize)]
842struct ApiSpec {
843 name: String,
844 content: String,
845}
846
847#[derive(Debug, Deserialize)]
848struct ApiProgress {
849 total: usize,
850 complete: usize,
851 shelved: usize,
852 in_progress: usize,
853 pending: usize,
854 remaining: usize,
855}
856
857#[derive(Debug, Deserialize)]
858struct ApiTaskList {
859 #[allow(dead_code)]
860 change_id: String,
861 tasks: Vec<ApiTaskItem>,
862 progress: ApiProgress,
863 format: String,
864}
865
866#[derive(Debug, Deserialize)]
867struct ApiTaskItem {
868 id: String,
869 name: String,
870 wave: Option<u32>,
871 status: String,
872 #[serde(default)]
873 dependencies: Option<Vec<String>>,
874}
875
876#[derive(Debug, Deserialize)]
877struct ApiTaskMarkdown {
878 #[allow(dead_code)]
879 change_id: String,
880 content: Option<String>,
881}
882
883#[derive(Debug, Deserialize)]
884struct ApiTaskInitResult {
885 change_id: String,
886 path: Option<String>,
887 existed: bool,
888 revision: Option<String>,
889}
890
891#[derive(Debug, Deserialize)]
892struct ApiTaskMutationEnvelope {
893 change_id: String,
894 task: ApiTaskDetail,
895 revision: Option<String>,
896}
897
898#[derive(Debug, Deserialize)]
899struct ApiTaskDetail {
900 id: String,
901 name: String,
902 wave: Option<u32>,
903 status: String,
904 updated_at: Option<String>,
905 dependencies: Vec<String>,
906 files: Vec<String>,
907 action: String,
908 verify: Option<String>,
909 done_when: Option<String>,
910 kind: String,
911 header_line_index: usize,
912}
913
914#[derive(Debug, Deserialize)]
915struct ApiSpecSummary {
916 id: String,
917 path: String,
918 last_modified: String,
919}
920
921#[derive(Debug, Deserialize)]
922struct ApiSpecDocument {
923 id: String,
924 path: String,
925 markdown: String,
926 last_modified: String,
927}
928
929#[derive(Debug, Deserialize)]
930struct ApiSubModuleSummary {
931 id: String,
932 name: String,
933 change_count: u32,
934}
935
936#[derive(Debug, Deserialize)]
937struct ApiSubModule {
938 id: String,
939 name: String,
940 description: Option<String>,
941 change_count: u32,
942}
943
944#[derive(Debug, Deserialize)]
945struct ApiModuleSummary {
946 id: String,
947 name: String,
948 change_count: u32,
949 #[serde(default)]
950 sub_modules: Vec<ApiSubModuleSummary>,
951}
952
953#[derive(Debug, Deserialize)]
954struct ApiModule {
955 id: String,
956 name: String,
957 description: Option<String>,
958 #[serde(default)]
959 sub_modules: Vec<ApiSubModule>,
960}
961
962#[derive(Debug, Deserialize)]
963struct ApiErrorBody {
964 error: String,
965 code: String,
966}
967
968#[cfg(test)]
969mod backend_http_tests;