Skip to main content

docx_core/control/
metadata.rs

1use 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/// Input payload for upserting project metadata.
12#[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    /// Upserts a project and merges aliases.
25    ///
26    /// # Errors
27    /// Returns `ControlError` if the input is invalid or the store operation fails.
28    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()
79            && let Some(first_alias) = project.aliases.first()
80        {
81            project.name = Some(first_alias.clone());
82        }
83
84        project.search_text = build_project_search_text(&project);
85
86        Ok(self.store.upsert_project(project).await?)
87    }
88
89    /// Fetches a project by id.
90    ///
91    /// # Errors
92    /// Returns `ControlError` if the store query fails.
93    pub async fn get_project(&self, project_id: &str) -> Result<Option<Project>, ControlError> {
94        Ok(self.store.get_project(project_id).await?)
95    }
96
97    /// Fetches an ingest record by id.
98    ///
99    /// # Errors
100    /// Returns `ControlError` if the store query fails.
101    pub async fn get_ingest(&self, ingest_id: &str) -> Result<Option<Ingest>, ControlError> {
102        Ok(self.store.get_ingest(ingest_id).await?)
103    }
104
105    /// Fetches a document source by id.
106    ///
107    /// # Errors
108    /// Returns `ControlError` if the store query fails.
109    pub async fn get_doc_source(
110        &self,
111        doc_source_id: &str,
112    ) -> Result<Option<DocSource>, ControlError> {
113        Ok(self.store.get_doc_source(doc_source_id).await?)
114    }
115
116    /// Lists projects with an optional limit.
117    ///
118    /// # Errors
119    /// Returns `ControlError` if the store query fails.
120    pub async fn list_projects(&self, limit: usize) -> Result<Vec<Project>, ControlError> {
121        Ok(self.store.list_projects(limit).await?)
122    }
123
124    /// Lists ingests for a project.
125    ///
126    /// # Errors
127    /// Returns `ControlError` if the store query fails.
128    pub async fn list_ingests(
129        &self,
130        project_id: &str,
131        limit: usize,
132    ) -> Result<Vec<Ingest>, ControlError> {
133        Ok(self.store.list_ingests(project_id, limit).await?)
134    }
135
136    /// Lists document sources for a project, optionally filtered by ingest id.
137    ///
138    /// # Errors
139    /// Returns `ControlError` if the store query fails.
140    pub async fn list_doc_sources(
141        &self,
142        project_id: &str,
143        ingest_id: Option<&str>,
144        limit: usize,
145    ) -> Result<Vec<DocSource>, ControlError> {
146        Ok(self
147            .store
148            .list_doc_sources_by_project(project_id, ingest_id, limit)
149            .await?)
150    }
151
152    /// Searches projects by a name or alias pattern.
153    ///
154    /// # Errors
155    /// Returns `ControlError` if the store query fails.
156    pub async fn search_projects(
157        &self,
158        pattern: &str,
159        limit: usize,
160    ) -> Result<Vec<Project>, ControlError> {
161        Ok(self.store.search_projects(pattern, limit).await?)
162    }
163}
164
165fn merge_aliases(target: &mut Vec<String>, incoming: &[String]) {
166    let mut seen: HashSet<String> = target
167        .iter()
168        .map(|alias| alias.trim().to_lowercase())
169        .filter(|value| !value.is_empty())
170        .collect();
171
172    for alias in incoming {
173        let trimmed = alias.trim();
174        if trimmed.is_empty() {
175            continue;
176        }
177        let key = trimmed.to_lowercase();
178        if seen.insert(key) {
179            target.push(trimmed.to_string());
180        }
181    }
182}
183
184fn build_project_search_text(project: &Project) -> Option<String> {
185    let mut values = HashSet::new();
186    let mut ordered = Vec::new();
187
188    push_search_value(&mut values, &mut ordered, &project.project_id);
189    if let Some(name) = project.name.as_ref() {
190        push_search_value(&mut values, &mut ordered, name);
191    }
192    for alias in &project.aliases {
193        push_search_value(&mut values, &mut ordered, alias);
194    }
195
196    if ordered.is_empty() {
197        None
198    } else {
199        Some(ordered.join("|"))
200    }
201}
202
203fn push_search_value(values: &mut HashSet<String>, ordered: &mut Vec<String>, input: &str) {
204    let trimmed = input.trim();
205    if trimmed.is_empty() {
206        return;
207    }
208    let lowered = trimmed.to_lowercase();
209    if values.insert(lowered.clone()) {
210        ordered.push(lowered);
211    }
212}