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() && 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    /// Fetches a project by id.
88    ///
89    /// # Errors
90    /// Returns `ControlError` if the store query fails.
91    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    /// Fetches an ingest record by id.
96    ///
97    /// # Errors
98    /// Returns `ControlError` if the store query fails.
99    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    /// Fetches a document source by id.
104    ///
105    /// # Errors
106    /// Returns `ControlError` if the store query fails.
107    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    /// Lists projects with an optional limit.
115    ///
116    /// # Errors
117    /// Returns `ControlError` if the store query fails.
118    pub async fn list_projects(&self, limit: usize) -> Result<Vec<Project>, ControlError> {
119        Ok(self.store.list_projects(limit).await?)
120    }
121
122    /// Lists ingests for a project.
123    ///
124    /// # Errors
125    /// Returns `ControlError` if the store query fails.
126    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    /// Lists document sources for a project, optionally filtered by ingest id.
135    ///
136    /// # Errors
137    /// Returns `ControlError` if the store query fails.
138    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    /// Searches projects by a name or alias pattern.
151    ///
152    /// # Errors
153    /// Returns `ControlError` if the store query fails.
154    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}