adk_artifact/
scoped.rs

1use crate::service::{ArtifactService, ListRequest, LoadRequest, SaveRequest};
2use adk_core::{Artifacts, Part, Result};
3use async_trait::async_trait;
4use std::sync::Arc;
5
6/// Scoped wrapper around ArtifactService that binds session context.
7///
8/// This wrapper implements the simple `adk_core::Artifacts` trait by automatically
9/// injecting app_name, user_id, and session_id into service requests. This mirrors
10/// the adk-go architecture where agents use a simple API but service calls include
11/// full session scoping.
12///
13/// # Example
14///
15/// ```no_run
16/// use adk_artifact::{ScopedArtifacts, InMemoryArtifactService};
17/// use adk_core::{Artifacts, Part};
18/// use std::sync::Arc;
19///
20/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
21/// let service = Arc::new(InMemoryArtifactService::new());
22/// let artifacts = ScopedArtifacts::new(
23///     service,
24///     "my_app".to_string(),
25///     "user_123".to_string(),
26///     "session_456".to_string(),
27/// );
28///
29/// // Simple API - scoping is automatic
30/// let version = artifacts.save("report.pdf", &Part::text("data")).await?;
31/// let loaded = artifacts.load("report.pdf").await?;
32/// let files = artifacts.list().await?;
33/// # Ok(())
34/// # }
35/// ```
36pub struct ScopedArtifacts {
37    service: Arc<dyn ArtifactService>,
38    app_name: String,
39    user_id: String,
40    session_id: String,
41}
42
43impl ScopedArtifacts {
44    /// Creates a new scoped artifacts instance.
45    ///
46    /// # Arguments
47    ///
48    /// * `service` - The underlying artifact service
49    /// * `app_name` - Application name for scoping
50    /// * `user_id` - User ID for scoping
51    /// * `session_id` - Session ID for scoping
52    pub fn new(
53        service: Arc<dyn ArtifactService>,
54        app_name: String,
55        user_id: String,
56        session_id: String,
57    ) -> Self {
58        Self { service, app_name, user_id, session_id }
59    }
60}
61
62#[async_trait]
63impl Artifacts for ScopedArtifacts {
64    async fn save(&self, name: &str, data: &Part) -> Result<i64> {
65        let resp = self
66            .service
67            .save(SaveRequest {
68                app_name: self.app_name.clone(),
69                user_id: self.user_id.clone(),
70                session_id: self.session_id.clone(),
71                file_name: name.to_string(),
72                part: data.clone(),
73                version: None,
74            })
75            .await?;
76        Ok(resp.version)
77    }
78
79    async fn load(&self, name: &str) -> Result<Part> {
80        let resp = self
81            .service
82            .load(LoadRequest {
83                app_name: self.app_name.clone(),
84                user_id: self.user_id.clone(),
85                session_id: self.session_id.clone(),
86                file_name: name.to_string(),
87                version: None,
88            })
89            .await?;
90        Ok(resp.part)
91    }
92
93    async fn list(&self) -> Result<Vec<String>> {
94        let resp = self
95            .service
96            .list(ListRequest {
97                app_name: self.app_name.clone(),
98                user_id: self.user_id.clone(),
99                session_id: self.session_id.clone(),
100            })
101            .await?;
102        Ok(resp.file_names)
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::InMemoryArtifactService;
110
111    #[tokio::test]
112    async fn test_scoped_artifacts_session_isolation() {
113        let service = Arc::new(InMemoryArtifactService::new());
114
115        let sess1 = ScopedArtifacts::new(
116            service.clone(),
117            "app".to_string(),
118            "user".to_string(),
119            "sess1".to_string(),
120        );
121        let sess2 = ScopedArtifacts::new(
122            service.clone(),
123            "app".to_string(),
124            "user".to_string(),
125            "sess2".to_string(),
126        );
127
128        // Save different data to same filename in different sessions
129        sess1.save("file.txt", &Part::Text { text: "session 1 data".to_string() }).await.unwrap();
130        sess2.save("file.txt", &Part::Text { text: "session 2 data".to_string() }).await.unwrap();
131
132        // Load from each session - should get isolated data
133        let loaded1 = sess1.load("file.txt").await.unwrap();
134        let loaded2 = sess2.load("file.txt").await.unwrap();
135
136        match (loaded1, loaded2) {
137            (Part::Text { text: text1 }, Part::Text { text: text2 }) => {
138                assert_eq!(text1, "session 1 data");
139                assert_eq!(text2, "session 2 data");
140            }
141            _ => panic!("Expected Text parts"),
142        }
143    }
144
145    #[tokio::test]
146    async fn test_scoped_artifacts_list_isolation() {
147        let service = Arc::new(InMemoryArtifactService::new());
148
149        let sess1 = ScopedArtifacts::new(
150            service.clone(),
151            "app".to_string(),
152            "user".to_string(),
153            "sess1".to_string(),
154        );
155        let sess2 = ScopedArtifacts::new(
156            service.clone(),
157            "app".to_string(),
158            "user".to_string(),
159            "sess2".to_string(),
160        );
161
162        // Save files in different sessions
163        sess1.save("file1.txt", &Part::Text { text: "data1".to_string() }).await.unwrap();
164        sess2.save("file2.txt", &Part::Text { text: "data2".to_string() }).await.unwrap();
165
166        // List should only show session-specific files
167        let files1 = sess1.list().await.unwrap();
168        let files2 = sess2.list().await.unwrap();
169
170        assert_eq!(files1, vec!["file1.txt"]);
171        assert_eq!(files2, vec!["file2.txt"]);
172    }
173
174    #[tokio::test]
175    async fn test_scoped_artifacts_user_prefix() {
176        let service = Arc::new(InMemoryArtifactService::new());
177
178        let sess1 = ScopedArtifacts::new(
179            service.clone(),
180            "app".to_string(),
181            "user1".to_string(),
182            "sess1".to_string(),
183        );
184        let sess2 = ScopedArtifacts::new(
185            service.clone(),
186            "app".to_string(),
187            "user1".to_string(),
188            "sess2".to_string(),
189        );
190
191        // Save user-scoped artifact (with "user:" prefix)
192        sess1
193            .save("user:shared.txt", &Part::Text { text: "shared data".to_string() })
194            .await
195            .unwrap();
196
197        // Should be accessible from both sessions (user-scoped)
198        let loaded1 = sess1.load("user:shared.txt").await.unwrap();
199        let loaded2 = sess2.load("user:shared.txt").await.unwrap();
200
201        match (loaded1, loaded2) {
202            (Part::Text { text: text1 }, Part::Text { text: text2 }) => {
203                assert_eq!(text1, "shared data");
204                assert_eq!(text2, "shared data");
205            }
206            _ => panic!("Expected Text parts"),
207        }
208    }
209}