Skip to main content

cognee_lib/api/
forget.rs

1//! Unified deletion API -- `forget()`.
2//!
3//! Thin wrapper over [`DeleteService`] that maps a user-friendly
4//! [`ForgetTarget`] enum to the underlying [`DeleteScope`].
5//!
6//! Equivalent to Python's `cognee.api.v1.forget.forget()`.
7
8use cognee_database::IngestDb;
9use cognee_delete::{DeleteMode, DeleteRequest, DeleteResult, DeleteScope, DeleteService};
10use uuid::Uuid;
11
12use super::error::ApiError;
13
14/// A reference to a dataset, either by human-readable name or by UUID.
15///
16/// Matches Python's `dataset: Union[str, UUID]` API semantics in
17/// `cognee.api.v1.forget.forget()`.
18#[derive(Debug, Clone)]
19pub enum DatasetRef {
20    /// Dataset identified by name (scoped to the `owner_id` passed to
21    /// [`forget`]).
22    Name(String),
23    /// Dataset identified by UUID. Resolving a UUID back to a dataset name
24    /// requires an [`IngestDb`] connection.
25    Id(Uuid),
26}
27
28impl DatasetRef {
29    /// Resolve this reference to a dataset name, performing a reverse lookup
30    /// via [`IngestDb::get_dataset`] when the reference is a UUID.
31    ///
32    /// # Errors
33    /// * Returns [`ApiError::InvalidArgument`] if `self` is [`DatasetRef::Id`]
34    ///   and `db` is `None` (we cannot resolve a UUID without a DB connection).
35    /// * Returns [`ApiError::InvalidArgument`] if the dataset cannot be found
36    ///   in the database, or if the dataset is owned by a different user.
37    pub async fn to_name(
38        &self,
39        owner_id: Uuid,
40        db: Option<&dyn IngestDb>,
41    ) -> Result<String, ApiError> {
42        match self {
43            DatasetRef::Name(name) => Ok(name.clone()),
44            DatasetRef::Id(id) => {
45                let db = db.ok_or_else(|| {
46                    ApiError::InvalidArgument(
47                        "db connection required to resolve dataset UUID".to_string(),
48                    )
49                })?;
50                let dataset = db.get_dataset(*id).await.map_err(|e| {
51                    ApiError::InvalidArgument(format!("Dataset {id} lookup failed: {e}"))
52                })?;
53                let dataset = dataset
54                    .ok_or_else(|| ApiError::InvalidArgument(format!("Dataset {id} not found")))?;
55                if dataset.owner_id != owner_id {
56                    return Err(ApiError::InvalidArgument(format!(
57                        "Dataset {id} not owned by the requesting user"
58                    )));
59                }
60                Ok(dataset.name)
61            }
62        }
63    }
64}
65
66/// What to forget.
67#[derive(Debug, Clone)]
68pub enum ForgetTarget {
69    /// Delete a single data item from a specific dataset.
70    Item { data_id: Uuid, dataset: DatasetRef },
71    /// Delete an entire dataset (by name or UUID).
72    Dataset { dataset: DatasetRef },
73    /// Delete all data for the given owner.
74    All,
75    /// Wipe graph+vector for a dataset, keep files + Data/Dataset rows,
76    /// reset cognify pipeline status only. Mirrors Python's
77    /// `dataset_memory_only` forget target.
78    DatasetMemoryOnly { dataset: DatasetRef },
79    /// Same, for a single data item.
80    DataItemMemoryOnly { data_id: Uuid, dataset: DatasetRef },
81}
82
83/// Summary of a forget operation.
84#[derive(Debug, Clone)]
85pub struct ForgetResult {
86    pub target: String,
87    pub delete_result: DeleteResult,
88}
89
90/// Unified deletion entry point.
91///
92/// # Arguments
93/// * `target` - What to delete (item, dataset, or everything).
94/// * `owner_id` - The owner whose data is affected.
95/// * `delete_service` - Pre-configured [`DeleteService`] with all backends.
96/// * `db` - Database connection for name-to-ID resolution. Required when
97///   `target` references a dataset by [`DatasetRef::Id`]; optional otherwise
98///   (used for dataset existence validation when resolving by name).
99///
100/// # Errors
101/// Returns [`ApiError::InvalidArgument`] if the dataset cannot be found,
102/// or [`ApiError::Delete`] if the underlying delete operation fails.
103pub async fn forget(
104    target: ForgetTarget,
105    owner_id: Uuid,
106    delete_service: &DeleteService,
107    db: Option<&dyn IngestDb>,
108) -> Result<ForgetResult, ApiError> {
109    // Mirrors Python `send_telemetry("cognee.forget", ...)` from
110    // cognee/api/v1/forget/forget.py:79.
111    #[cfg(feature = "telemetry")]
112    {
113        let (target_label, dataset_dbg, data_id_dbg) = match &target {
114            ForgetTarget::Item { data_id, dataset } => {
115                ("data_item", format!("{dataset:?}"), data_id.to_string())
116            }
117            ForgetTarget::Dataset { dataset } => ("dataset", format!("{dataset:?}"), String::new()),
118            ForgetTarget::All => ("everything", String::new(), String::new()),
119            ForgetTarget::DatasetMemoryOnly { dataset } => {
120                ("dataset_memory_only", format!("{dataset:?}"), String::new())
121            }
122            ForgetTarget::DataItemMemoryOnly { data_id, dataset } => (
123                "data_item_memory_only",
124                format!("{dataset:?}"),
125                data_id.to_string(),
126            ),
127        };
128        cognee_telemetry::send_telemetry(
129            "cognee.forget",
130            owner_id,
131            Some(serde_json::json!({
132                "target": target_label,
133                "dataset": dataset_dbg,
134                "data_id": data_id_dbg,
135                "cognee_version": env!("CARGO_PKG_VERSION"),
136            })),
137        );
138    }
139
140    let (scope, memory_only, label) = match target {
141        ForgetTarget::Item { data_id, dataset } => {
142            let dataset_name = dataset.to_name(owner_id, db).await?;
143            let scope = DeleteScope::Data {
144                owner_id,
145                data_id,
146                dataset_name: Some(dataset_name),
147                delete_dataset_if_empty: false,
148            };
149            (scope, false, format!("item:{data_id}"))
150        }
151        ForgetTarget::Dataset { dataset } => {
152            let dataset_name = dataset.to_name(owner_id, db).await?;
153            // Validate dataset exists if we have a DB connection and the
154            // reference came in as a name (UUID path already validated above).
155            if let Some(db) = db {
156                let _dataset = db
157                    .get_dataset_by_name(&dataset_name, owner_id, None)
158                    .await
159                    .map_err(|e| {
160                        ApiError::InvalidArgument(format!(
161                            "Dataset '{dataset_name}' not found: {e}"
162                        ))
163                    })?;
164            }
165            let scope = DeleteScope::Dataset {
166                owner_id,
167                dataset_name: dataset_name.clone(),
168            };
169            (scope, false, format!("dataset:{dataset_name}"))
170        }
171        ForgetTarget::All => {
172            let scope = DeleteScope::User { owner_id };
173            (scope, false, "all".to_string())
174        }
175        ForgetTarget::DatasetMemoryOnly { dataset } => {
176            let dataset_name = dataset.to_name(owner_id, db).await?;
177            let scope = DeleteScope::Dataset {
178                owner_id,
179                dataset_name: dataset_name.clone(),
180            };
181            (scope, true, format!("dataset_memory_only:{dataset_name}"))
182        }
183        ForgetTarget::DataItemMemoryOnly { data_id, dataset } => {
184            let dataset_name = dataset.to_name(owner_id, db).await?;
185            let scope = DeleteScope::Data {
186                owner_id,
187                data_id,
188                dataset_name: Some(dataset_name),
189                delete_dataset_if_empty: false,
190            };
191            (scope, true, format!("data_item_memory_only:{data_id}"))
192        }
193    };
194
195    let request = build_delete_request(scope, memory_only);
196
197    let delete_result = delete_service.execute(&request).await?;
198
199    Ok(ForgetResult {
200        target: label,
201        delete_result,
202    })
203}
204
205/// Build the [`DeleteRequest`] for a `forget` operation.
206///
207/// Extracted so the delete mode choice can be unit-tested independently of the
208/// async scope-resolution logic.
209fn build_delete_request(scope: DeleteScope, memory_only: bool) -> DeleteRequest {
210    DeleteRequest {
211        scope,
212        // Python `datasets.delete_data` defaults mode="soft" and warns hard is
213        // dangerous (datasets.py:147). Match the safer default.
214        mode: DeleteMode::Soft,
215        memory_only,
216    }
217}
218
219#[cfg(test)]
220#[allow(
221    clippy::unwrap_used,
222    clippy::expect_used,
223    reason = "test code — panics are acceptable failures"
224)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn forget_target_debug_format() {
230        let target = ForgetTarget::All;
231        let debug_str = format!("{target:?}");
232        assert!(debug_str.contains("All"));
233    }
234
235    #[test]
236    fn forget_target_item_holds_fields() {
237        let id = Uuid::new_v4();
238        let target = ForgetTarget::Item {
239            data_id: id,
240            dataset: DatasetRef::Name("test_ds".to_string()),
241        };
242        match target {
243            ForgetTarget::Item { data_id, dataset } => {
244                assert_eq!(data_id, id);
245                match dataset {
246                    DatasetRef::Name(name) => assert_eq!(name, "test_ds"),
247                    _ => panic!("expected Name variant"),
248                }
249            }
250            _ => panic!("expected Item variant"),
251        }
252    }
253
254    // ----- Unit tests for DatasetRef::to_name -----
255
256    #[tokio::test]
257    async fn dataset_ref_name_passthrough() {
258        // DatasetRef::Name with db=None should return the name without any DB
259        // lookup.
260        let owner_id = Uuid::new_v4();
261        let dref = DatasetRef::Name("my_ds".to_string());
262        let resolved = dref.to_name(owner_id, None).await.expect("passthrough ok");
263        assert_eq!(resolved, "my_ds");
264    }
265
266    #[tokio::test]
267    async fn dataset_ref_id_requires_db() {
268        // DatasetRef::Id with db=None must error with InvalidArgument.
269        let owner_id = Uuid::new_v4();
270        let dref = DatasetRef::Id(Uuid::new_v4());
271        let result = dref.to_name(owner_id, None).await;
272        match result {
273            Err(ApiError::InvalidArgument(msg)) => {
274                assert!(
275                    msg.contains("db connection required"),
276                    "unexpected msg: {msg}"
277                );
278            }
279            other => panic!("expected InvalidArgument, got {other:?}"),
280        }
281    }
282
283    #[test]
284    fn forget_target_dataset_uuid_variant_debug() {
285        // Ensure the new UUID variant round-trips through Debug.
286        let id = Uuid::new_v4();
287        let target = ForgetTarget::Dataset {
288            dataset: DatasetRef::Id(id),
289        };
290        let dbg = format!("{target:?}");
291        assert!(dbg.contains("Dataset"), "debug: {dbg}");
292        assert!(dbg.contains("Id"), "debug: {dbg}");
293    }
294}