docx_core/control/
metadata.rs1use std::collections::HashSet;
2
3use docx_store::models::{DocSource, Ingest, Project};
4use serde::{Deserialize, Serialize};
5use surrealdb::Connection;
6
7use crate::store::StoreError;
8
9use super::{ControlError, DocxControlPlane};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ProjectUpsertRequest {
14 pub project_id: String,
15 pub name: Option<String>,
16 pub language: Option<String>,
17 pub root_path: Option<String>,
18 pub description: Option<String>,
19 #[serde(default)]
20 pub aliases: Vec<String>,
21}
22
23impl<C: Connection> DocxControlPlane<C> {
24 pub async fn upsert_project(
29 &self,
30 request: ProjectUpsertRequest,
31 ) -> Result<Project, ControlError> {
32 let ProjectUpsertRequest {
33 project_id,
34 name,
35 language,
36 root_path,
37 description,
38 aliases,
39 } = request;
40
41 if project_id.trim().is_empty() {
42 return Err(ControlError::Store(StoreError::InvalidInput(
43 "project_id is required".to_string(),
44 )));
45 }
46
47 let mut project = self
48 .store
49 .get_project(&project_id)
50 .await?
51 .unwrap_or_else(|| Project {
52 id: None,
53 project_id: project_id.clone(),
54 name: None,
55 language: None,
56 root_path: None,
57 description: None,
58 aliases: Vec::new(),
59 search_text: None,
60 extra: None,
61 });
62
63 if let Some(name) = name {
64 project.name = Some(name);
65 }
66 if let Some(language) = language {
67 project.language = Some(language);
68 }
69 if let Some(root_path) = root_path {
70 project.root_path = Some(root_path);
71 }
72 if let Some(description) = description {
73 project.description = Some(description);
74 }
75
76 merge_aliases(&mut project.aliases, &aliases);
77
78 if project.name.is_none() && let Some(first_alias) = project.aliases.first() {
79 project.name = Some(first_alias.clone());
80 }
81
82 project.search_text = build_project_search_text(&project);
83
84 Ok(self.store.upsert_project(project).await?)
85 }
86
87 pub async fn get_project(&self, project_id: &str) -> Result<Option<Project>, ControlError> {
92 Ok(self.store.get_project(project_id).await?)
93 }
94
95 pub async fn get_ingest(&self, ingest_id: &str) -> Result<Option<Ingest>, ControlError> {
100 Ok(self.store.get_ingest(ingest_id).await?)
101 }
102
103 pub async fn get_doc_source(
108 &self,
109 doc_source_id: &str,
110 ) -> Result<Option<DocSource>, ControlError> {
111 Ok(self.store.get_doc_source(doc_source_id).await?)
112 }
113
114 pub async fn list_projects(&self, limit: usize) -> Result<Vec<Project>, ControlError> {
119 Ok(self.store.list_projects(limit).await?)
120 }
121
122 pub async fn list_ingests(
127 &self,
128 project_id: &str,
129 limit: usize,
130 ) -> Result<Vec<Ingest>, ControlError> {
131 Ok(self.store.list_ingests(project_id, limit).await?)
132 }
133
134 pub async fn list_doc_sources(
139 &self,
140 project_id: &str,
141 ingest_id: Option<&str>,
142 limit: usize,
143 ) -> Result<Vec<DocSource>, ControlError> {
144 Ok(self
145 .store
146 .list_doc_sources_by_project(project_id, ingest_id, limit)
147 .await?)
148 }
149
150 pub async fn search_projects(
155 &self,
156 pattern: &str,
157 limit: usize,
158 ) -> Result<Vec<Project>, ControlError> {
159 Ok(self.store.search_projects(pattern, limit).await?)
160 }
161}
162
163fn merge_aliases(target: &mut Vec<String>, incoming: &[String]) {
164 let mut seen: HashSet<String> = target
165 .iter()
166 .map(|alias| alias.trim().to_lowercase())
167 .filter(|value| !value.is_empty())
168 .collect();
169
170 for alias in incoming {
171 let trimmed = alias.trim();
172 if trimmed.is_empty() {
173 continue;
174 }
175 let key = trimmed.to_lowercase();
176 if seen.insert(key) {
177 target.push(trimmed.to_string());
178 }
179 }
180}
181
182fn build_project_search_text(project: &Project) -> Option<String> {
183 let mut values = HashSet::new();
184 let mut ordered = Vec::new();
185
186 push_search_value(&mut values, &mut ordered, &project.project_id);
187 if let Some(name) = project.name.as_ref() {
188 push_search_value(&mut values, &mut ordered, name);
189 }
190 for alias in &project.aliases {
191 push_search_value(&mut values, &mut ordered, alias);
192 }
193
194 if ordered.is_empty() {
195 None
196 } else {
197 Some(ordered.join("|"))
198 }
199}
200
201fn push_search_value(values: &mut HashSet<String>, ordered: &mut Vec<String>, input: &str) {
202 let trimmed = input.trim();
203 if trimmed.is_empty() {
204 return;
205 }
206 let lowered = trimmed.to_lowercase();
207 if values.insert(lowered.clone()) {
208 ordered.push(lowered);
209 }
210}