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