Skip to main content

agent_diva_files/
channel.rs

1//! # 频道文件管理模块 - Channel File Management Module
2//!
3//! 本模块实现了**频道文件上传支持**,允许在逻辑上隔离的文件管理。
4//! 一个文件可以属于多个频道,实现共享和复用。
5//!
6//! ## 核心概念
7//!
8//! ### 频道 vs 文件
9//!
10//! 在这个实现中,**文件存储是全局的**(复用 SHA256 去重),
11//! 但每个文件可以与多个**频道**关联。
12//!
13//! ```text
14//! 全局文件存储:
15//!   sha256:abc123 → /data/ab/c123 (物理存储)
16//!                      ↑
17//!                      │ 引用计数 ref_count = 3
18//!
19//! 频道关联:
20//!   channel:telegram:chat_1 → sha256:abc123 (通过 channel_files 表)
21//!   channel:discord:server_2 → sha256:abc123
22//! ```
23//!
24//! 这样,同一个文件在物理上只存储一份,但在逻辑上可以属于多个频道。
25//!
26//! ### 数据库设计
27//!
28//! 新增 `channel_files` 关联表:
29//!
30//! | 字段 | 类型 | 说明 |
31//! |------|------|------|
32//! | id | INTEGER | 主键 |
33//! | channel_id | TEXT | 频道标识符 |
34//! | file_id | TEXT | 关联的文件ID (外键) |
35//! | uploaded_by | TEXT | 上传者标识 |
36//! | uploaded_at | TEXT | 上传时间 |
37//! | message_id | TEXT | 关联的消息ID (可选) |
38//!
39//! ## 使用场景
40//!
41//! ### 场景1: Telegram 群组文件管理
42//!
43//! ```ignore
44//! // 上传文件到 Telegram 频道
45//! let handle = channel_manager
46//!     .upload_to_channel("telegram:chat_123", data, metadata)
47//!     .await?;
48//!
49//! // 列出该群组的所有文件
50//! let files = channel_manager.list_channel_files("telegram:chat_123").await?;
51//!
52//! // 获取特定文件
53//! let file = channel_manager.get_channel_file("telegram:chat_123", &handle.id).await?;
54//! ```
55//!
56//! ### 场景2: Discord 服务器文件共享
57//!
58//! ```ignore
59//! // 在 Discord 服务器上传文件
60//! let handle = channel_manager
61//!     .upload_to_channel("discord:server_456:channel_789", data, metadata)
62//!     .await?;
63//!
64//! // 如果同一个文件已在其他频道存在,直接创建关联(节省存储)
65//! // 文件ID相同,但 channel_files 表中会新增一条记录
66//! ```
67//!
68//! ### 场景3: 清理频道时保留共享文件
69//!
70//! ```ignore
71//! // 删除频道时,cleanup=false 只移除关联,不删除物理文件
72//! channel_manager.delete_channel("discord:server_456:channel_789", false).await?;
73//!
74//! // 如果其他频道还在用这个文件,物理文件不会被删除
75//! // 只有当 ref_count = 0 且没有任何频道关联时,才会被清理
76//! ```
77//!
78//! ## ChannelManager 使用方法
79//!
80//! ### 基本用法
81//!
82//! ```rust,ignore
83//! use agent_diva_files::{FileManager, channel::{ChannelManager, ChannelFileInfo}};
84//!
85//! // 创建 ChannelManager(需要已有的 FileManager)
86//! let channel_manager = ChannelManager::new(file_manager.clone());
87//!
88//! // 上传文件到频道
89//! let handle = channel_manager
90//!     .upload_to_channel("my-channel", b"hello world", metadata)
91//!     .await?;
92//!
93//! // 列出频道文件
94//! let files = channel_manager.list_channel_files("my-channel").await?;
95//! for file_info in files {
96//!     println!("File: {} (uploaded by {:?})",
97//!              file_info.file.id,
98//!              file_info.uploaded_by);
99//! }
100//! ```
101//!
102//! ## 频道ID格式约定
103//!
104//! 频道ID的格式由应用层决定,建议格式:
105//!
106//! | 平台 | 格式 | 示例 |
107//! |------|------|------|
108//! | Telegram | `telegram:chat_{id}` | `telegram:chat_123456` |
109//! | Discord | `discord:{server}:{channel}` | `discord:987654:channel_111` |
110//! | Slack | `slack:{team}:{channel}` | `slack:T01234:C56789` |
111//! | UI | `ui:project_{id}` | `ui:project_42` |
112//!
113//! 格式约定的目的是:
114//! - 避免不同平台的文件冲突
115//! - 便于调试和追踪
116//! - 但 ChannelManager 本身不强制验证格式
117
118use crate::handle::{FileHandle, FileIndexEntry, FileMetadata};
119use crate::manager::FileManager;
120use crate::Result;
121use chrono::{DateTime, Utc};
122use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
123use sqlx::{Row, SqlitePool};
124use std::path::PathBuf;
125use std::sync::Arc;
126
127/// Channel file information - combines file entry with channel-specific metadata
128#[derive(Debug, Clone)]
129pub struct ChannelFileInfo {
130    /// The file entry (shared across all channels)
131    pub file: FileIndexEntry,
132
133    /// Who uploaded this file to this channel
134    pub uploaded_by: Option<String>,
135
136    /// When the file was uploaded to this channel
137    pub uploaded_at: DateTime<Utc>,
138
139    /// Associated message ID (platform-specific)
140    pub message_id: Option<String>,
141}
142
143/// Channel statistics
144#[derive(Debug, Clone)]
145pub struct ChannelStats {
146    /// The channel ID
147    pub channel_id: String,
148
149    /// Total number of file references in this channel
150    pub total_files: usize,
151
152    /// Total size of all files in this channel (accounting for sharing)
153    pub total_size: u64,
154
155    /// Number of files that are unique to this channel
156    /// (files not shared with any other channel)
157    pub unique_files: usize,
158}
159
160/// Channel manager - handles channel-specific file operations
161///
162/// Provides logical isolation and tracking for files across different channels.
163/// The actual file storage is global and shared via SHA256 deduplication.
164pub struct ChannelManager {
165    /// Shared file manager for actual storage
166    file_manager: Arc<FileManager>,
167
168    /// Database pool for channel-specific metadata
169    pool: SqlitePool,
170
171    /// Database path (for debugging/reconnection)
172    #[allow(dead_code)]
173    db_path: PathBuf,
174}
175
176impl ChannelManager {
177    /// Create a new channel manager
178    ///
179    /// # Arguments
180    /// * `file_manager` - Shared FileManager for file storage
181    /// * `db_path` - Path to the channel metadata database
182    ///
183    /// # Example
184    /// ```ignore
185    /// use agent_diva_files::FileManager;
186    /// use agent_diva_files::channel::ChannelManager;
187    ///
188    /// let file_manager = FileManager::new(config).await?;
189    /// let channel_manager = ChannelManager::new(
190    ///     Arc::new(file_manager),
191    ///     PathBuf::from("channels.db"),
192    /// ).await?;
193    /// ```
194    pub async fn new(file_manager: Arc<FileManager>, db_path: PathBuf) -> Result<Self> {
195        // Ensure parent directory exists
196        if let Some(parent) = db_path.parent() {
197            tokio::fs::create_dir_all(parent).await?;
198        }
199
200        let options = SqliteConnectOptions::new()
201            .filename(&db_path)
202            .create_if_missing(true);
203
204        let pool = SqlitePoolOptions::new()
205            .max_connections(3)
206            .connect_with(options)
207            .await?;
208
209        let manager = Self {
210            file_manager,
211            pool,
212            db_path: db_path.clone(),
213        };
214
215        manager.init_schema().await?;
216
217        tracing::info!("ChannelManager initialized with database at {:?}", db_path);
218        Ok(manager)
219    }
220
221    /// Initialize the database schema
222    async fn init_schema(&self) -> Result<()> {
223        sqlx::query(
224            r#"
225            CREATE TABLE IF NOT EXISTS channel_files (
226                id INTEGER PRIMARY KEY AUTOINCREMENT,
227                channel_id TEXT NOT NULL,
228                file_id TEXT NOT NULL,
229                uploaded_by TEXT,
230                uploaded_at TEXT NOT NULL,
231                message_id TEXT,
232                UNIQUE(channel_id, file_id)
233            );
234
235            CREATE INDEX IF NOT EXISTS idx_channel_files_channel ON channel_files(channel_id);
236            CREATE INDEX IF NOT EXISTS idx_channel_files_file ON channel_files(file_id);
237            CREATE INDEX IF NOT EXISTS idx_channel_files_uploaded_at ON channel_files(uploaded_at);
238            "#,
239        )
240        .execute(&self.pool)
241        .await?;
242
243        Ok(())
244    }
245
246    // ==========================================================================
247    // File Upload Operations
248    // ==========================================================================
249
250    /// Upload a file to a specific channel
251    ///
252    /// If the file content already exists (same SHA256 hash), it will be
253    /// deduplicated and associated with the new channel instead of storing
254    /// duplicate data.
255    ///
256    /// # Arguments
257    /// * `channel_id` - The channel identifier (e.g., "telegram:chat_123")
258    /// * `data` - File content bytes
259    /// * `metadata` - File metadata (name, size, mime_type, etc.)
260    /// * `uploaded_by` - Optional uploader identifier
261    /// * `message_id` - Optional associated message ID
262    ///
263    /// # Returns
264    /// A `FileHandle` for the stored file
265    ///
266    /// # Example
267    /// ```ignore
268    /// let handle = channel_manager
269    ///     .upload_to_channel(
270    ///         "telegram:chat_123",
271    ///         b"file content",
272    ///         FileMetadata { name: "document.pdf", .. },
273    ///         Some("user_456"),
274    ///         Some("msg_789"),
275    ///     )
276    ///     .await?;
277    /// ```
278    pub async fn upload_to_channel(
279        &self,
280        channel_id: &str,
281        data: &[u8],
282        metadata: FileMetadata,
283        uploaded_by: Option<&str>,
284        message_id: Option<&str>,
285    ) -> Result<FileHandle> {
286        // Step 1: Store the file globally (deduplication happens here)
287        let handle = self.file_manager.store(data, metadata).await?;
288
289        // Step 2: Create channel association
290        self.add_file_to_channel(channel_id, &handle.id, uploaded_by, message_id)
291            .await?;
292
293        tracing::info!(
294            "Uploaded file {} to channel {} (uploaded by {:?})",
295            handle.id,
296            channel_id,
297            uploaded_by
298        );
299
300        Ok(handle)
301    }
302
303    /// Add an existing file to a channel
304    ///
305    /// Use this when a file has already been uploaded and you just want
306    /// to associate it with a channel.
307    ///
308    /// # Returns
309    /// `Ok(true)` if a new association was created, `Ok(false)` if it already existed
310    pub async fn add_file_to_channel(
311        &self,
312        channel_id: &str,
313        file_id: &str,
314        uploaded_by: Option<&str>,
315        message_id: Option<&str>,
316    ) -> Result<bool> {
317        let result = sqlx::query(
318            r#"
319            INSERT OR IGNORE INTO channel_files (channel_id, file_id, uploaded_by, uploaded_at, message_id)
320            VALUES (?, ?, ?, ?, ?)
321            "#,
322        )
323        .bind(channel_id)
324        .bind(file_id)
325        .bind(uploaded_by)
326        .bind(Utc::now().to_rfc3339())
327        .bind(message_id)
328        .execute(&self.pool)
329        .await?;
330
331        Ok(result.rows_affected() > 0)
332    }
333
334    // ==========================================================================
335    // File Listing and Retrieval
336    // ==========================================================================
337
338    /// List all files in a channel
339    ///
340    /// Returns files with their channel-specific metadata (uploader, upload time, etc.)
341    ///
342    /// # Arguments
343    /// * `channel_id` - The channel to list files for
344    ///
345    /// # Returns
346    /// List of files in the channel, newest first
347    ///
348    /// # Example
349    /// ```ignore
350    /// let files = channel_manager.list_channel_files("telegram:chat_123").await?;
351    /// for info in files {
352    ///     println!("{} - uploaded by {:?} at {}",
353    ///              info.file.id, info.uploaded_by, info.uploaded_at);
354    /// }
355    /// ```
356    pub async fn list_channel_files(&self, channel_id: &str) -> Result<Vec<ChannelFileInfo>> {
357        let rows = sqlx::query(
358            r#"
359            SELECT
360                cf.channel_id, cf.file_id, cf.uploaded_by, cf.uploaded_at, cf.message_id,
361                f.id, f.path, f.size, f.ref_count, f.created_at, f.last_accessed_at, f.metadata_json
362            FROM channel_files cf
363            JOIN files f ON cf.file_id = f.id
364            WHERE cf.channel_id = ? AND f.deleted_at IS NULL
365            ORDER BY cf.uploaded_at DESC
366            "#,
367        )
368        .bind(channel_id)
369        .fetch_all(&self.pool)
370        .await?;
371
372        let mut results = Vec::new();
373        for row in rows {
374            let file = self.row_to_entry(&row)?;
375            let channel_info = ChannelFileInfo {
376                file,
377                uploaded_by: row.get("uploaded_by"),
378                uploaded_at: DateTime::parse_from_rfc3339(&row.get::<String, _>("uploaded_at"))
379                    .map(|dt| dt.with_timezone(&Utc))
380                    .unwrap_or_else(|_| Utc::now()),
381                message_id: row.get("message_id"),
382            };
383            results.push(channel_info);
384        }
385
386        Ok(results)
387    }
388
389    /// Get a specific file from a channel
390    ///
391    /// # Arguments
392    /// * `channel_id` - The channel ID
393    /// * `file_id` - The file ID to retrieve
394    ///
395    /// # Returns
396    /// `FileHandle` if found, error otherwise
397    pub async fn get_channel_file(&self, channel_id: &str, file_id: &str) -> Result<FileHandle> {
398        // First check if this file is associated with this channel
399        let exists = sqlx::query(
400            r#"
401            SELECT 1 FROM channel_files
402            WHERE channel_id = ? AND file_id = ?
403            "#,
404        )
405        .bind(channel_id)
406        .bind(file_id)
407        .fetch_optional(&self.pool)
408        .await?;
409
410        if exists.is_none() {
411            return Err(crate::FileError::NotFound(format!(
412                "File {} not found in channel {}",
413                file_id, channel_id
414            )));
415        }
416
417        // Get the file handle from the file manager
418        self.file_manager.get(file_id).await
419    }
420
421    /// List all channels a file belongs to
422    ///
423    /// # Returns
424    /// List of channel IDs that contain this file
425    pub async fn list_file_channels(&self, file_id: &str) -> Result<Vec<String>> {
426        let rows = sqlx::query(
427            r#"
428            SELECT channel_id FROM channel_files
429            WHERE file_id = ?
430            ORDER BY uploaded_at DESC
431            "#,
432        )
433        .bind(file_id)
434        .fetch_all(&self.pool)
435        .await?;
436
437        Ok(rows.iter().map(|r| r.get("channel_id")).collect())
438    }
439
440    // ==========================================================================
441    // File Removal
442    // ==========================================================================
443
444    /// Remove a file from a channel (but don't delete the actual file)
445    ///
446    /// This only removes the channel association, not the physical file.
447    /// The file will continue to exist if other channels reference it.
448    ///
449    /// # Arguments
450    /// * `channel_id` - The channel to remove from
451    /// * `file_id` - The file to remove
452    ///
453    /// # Returns
454    /// `Ok(true)` if association was removed, `Ok(false)` if it didn't exist
455    pub async fn remove_from_channel(&self, channel_id: &str, file_id: &str) -> Result<bool> {
456        let result = sqlx::query(
457            r#"
458            DELETE FROM channel_files
459            WHERE channel_id = ? AND file_id = ?
460            "#,
461        )
462        .bind(channel_id)
463        .bind(file_id)
464        .execute(&self.pool)
465        .await?;
466
467        if result.rows_affected() > 0 {
468            tracing::info!("Removed file {} from channel {}", file_id, channel_id);
469        }
470
471        Ok(result.rows_affected() > 0)
472    }
473
474    /// Delete an entire channel
475    ///
476    /// # Arguments
477    /// * `channel_id` - The channel to delete
478    /// * `cleanup` - If `true`, also delete files that are only in this channel
479    ///   If `false`, only remove the channel associations
480    ///
481    /// # Behavior
482    ///
483    /// With `cleanup = false`:
484    /// - Only removes channel_file associations
485    /// - Physical files remain and can still be accessed via other channels
486    ///
487    /// With `cleanup = true`:
488    /// - Removes channel_file associations
489    /// - For files unique to this channel, also soft-deletes them
490    /// - Files shared with other channels are NOT deleted
491    ///
492    /// # Example
493    /// ```ignore
494    /// // Delete channel but keep shared files
495    /// channel_manager.delete_channel("temp_channel", false).await?;
496    ///
497    /// // Delete channel and cleanup unique files
498    /// channel_manager.delete_channel("archive_channel", true).await?;
499    /// ```
500    pub async fn delete_channel(&self, channel_id: &str, cleanup: bool) -> Result<usize> {
501        if cleanup {
502            // Find files unique to this channel
503            let unique_files = self.find_unique_channel_files(channel_id).await?;
504            let mut deleted = 0;
505
506            // Soft delete unique files
507            for file_id in unique_files {
508                if self
509                    .file_manager
510                    .soft_delete(&file_id, Some(channel_id))
511                    .await?
512                {
513                    deleted += 1;
514                }
515            }
516
517            // Remove all channel associations
518            sqlx::query("DELETE FROM channel_files WHERE channel_id = ?")
519                .bind(channel_id)
520                .execute(&self.pool)
521                .await?;
522
523            tracing::info!(
524                "Deleted channel {} and soft-deleted {} unique files",
525                channel_id,
526                deleted
527            );
528
529            Ok(deleted)
530        } else {
531            // Just remove associations
532            let result = sqlx::query("DELETE FROM channel_files WHERE channel_id = ?")
533                .bind(channel_id)
534                .execute(&self.pool)
535                .await?;
536
537            tracing::info!(
538                "Deleted channel {} associations (cleanup=false, files preserved)",
539                channel_id
540            );
541
542            Ok(result.rows_affected() as usize)
543        }
544    }
545
546    /// Find files that are unique to a channel (not in any other channel)
547    async fn find_unique_channel_files(&self, channel_id: &str) -> Result<Vec<String>> {
548        let rows = sqlx::query(
549            r#"
550            SELECT cf.file_id
551            FROM channel_files cf
552            WHERE cf.channel_id = ?
553            AND cf.file_id NOT IN (
554                SELECT file_id FROM channel_files WHERE channel_id != ?
555            )
556            "#,
557        )
558        .bind(channel_id)
559        .bind(channel_id)
560        .fetch_all(&self.pool)
561        .await?;
562
563        Ok(rows.iter().map(|r| r.get("file_id")).collect())
564    }
565
566    // ==========================================================================
567    // Statistics
568    // ==========================================================================
569
570    /// Get statistics for a channel
571    ///
572    /// # Returns
573    /// `ChannelStats` with total files, total size, and unique files count
574    pub async fn channel_stats(&self, channel_id: &str) -> Result<ChannelStats> {
575        let row = sqlx::query(
576            r#"
577            SELECT
578                COUNT(*) as total_files,
579                COALESCE(SUM(f.size), 0) as total_size,
580                COUNT(DISTINCT cf.file_id) as unique_files
581            FROM channel_files cf
582            JOIN files f ON cf.file_id = f.id
583            WHERE cf.channel_id = ? AND f.deleted_at IS NULL
584            "#,
585        )
586        .bind(channel_id)
587        .fetch_one(&self.pool)
588        .await?;
589
590        Ok(ChannelStats {
591            channel_id: channel_id.to_string(),
592            total_files: row.get::<i64, _>("total_files") as usize,
593            total_size: row.get::<i64, _>("total_size") as u64,
594            unique_files: row.get::<i64, _>("unique_files") as usize,
595        })
596    }
597
598    /// List all channels that have files
599    ///
600    /// # Returns
601    /// List of channel IDs with at least one file
602    pub async fn list_channels(&self) -> Result<Vec<String>> {
603        let rows = sqlx::query(
604            r#"
605            SELECT DISTINCT channel_id FROM channel_files
606            ORDER BY channel_id
607            "#,
608        )
609        .fetch_all(&self.pool)
610        .await?;
611
612        Ok(rows.iter().map(|r| r.get("channel_id")).collect())
613    }
614
615    // ==========================================================================
616    // Helper Methods
617    // ==========================================================================
618
619    /// Convert a database row to FileIndexEntry
620    ///
621    /// The row must contain columns: id, path, size, ref_count, created_at, last_accessed_at, metadata_json
622    fn row_to_entry(&self, row: &sqlx::sqlite::SqliteRow) -> Result<FileIndexEntry> {
623        use crate::handle::FileMetadata;
624        use chrono::DateTime;
625
626        let metadata_json: String = row.get("metadata_json");
627        let metadata: FileMetadata = serde_json::from_str(&metadata_json)?;
628
629        Ok(FileIndexEntry {
630            id: row.get("id"),
631            path: PathBuf::from(row.get::<String, _>("path")),
632            size: row.get::<i64, _>("size") as u64,
633            ref_count: row.get::<i64, _>("ref_count") as usize,
634            created_at: DateTime::parse_from_rfc3339(&row.get::<String, _>("created_at"))?
635                .with_timezone(&Utc),
636            last_accessed_at: row
637                .get::<Option<String>, _>("last_accessed_at")
638                .map(|s| DateTime::parse_from_rfc3339(&s).map(|dt| dt.with_timezone(&Utc)))
639                .transpose()?,
640            metadata,
641        })
642    }
643
644    /// Close the channel database connection
645    pub async fn close(&self) {
646        self.pool.close().await;
647    }
648}
649
650// ============================================================================
651// Unit Tests
652// ============================================================================
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657    use crate::config::FileConfig;
658    use tempfile::TempDir;
659
660    async fn create_test_managers() -> (ChannelManager, TempDir) {
661        let temp_dir = TempDir::new().unwrap();
662
663        // Create FileManager
664        let config = FileConfig::with_path(temp_dir.path().join("files"));
665        let file_manager = FileManager::new(config).await.unwrap();
666
667        // Create ChannelManager using the SAME database as FileManager
668        // FileManager stores index at storage_path.join("index.db")
669        let channel_db = temp_dir.path().join("files/index.db");
670        let channel_manager = ChannelManager::new(Arc::new(file_manager), channel_db)
671            .await
672            .unwrap();
673
674        (channel_manager, temp_dir)
675    }
676
677    fn create_test_metadata(name: &str) -> FileMetadata {
678        FileMetadata {
679            name: name.to_string(),
680            size: 100,
681            mime_type: Some("text/plain".to_string()),
682            source: Some("test".to_string()),
683            created_at: chrono::Utc::now(),
684            last_accessed_at: None,
685            preview: None,
686        }
687    }
688
689    #[tokio::test]
690    async fn test_upload_to_channel() {
691        let (manager, _temp) = create_test_managers().await;
692
693        let data = b"hello channel";
694        let metadata = create_test_metadata("test.txt");
695
696        // Upload to first channel
697        let handle = manager
698            .upload_to_channel("channel:1", data, metadata.clone(), Some("user1"), None)
699            .await
700            .unwrap();
701
702        assert!(handle.id.starts_with("sha256:"));
703
704        // List files in channel
705        let files = manager.list_channel_files("channel:1").await.unwrap();
706        assert_eq!(files.len(), 1);
707        assert_eq!(files[0].uploaded_by, Some("user1".to_string()));
708    }
709
710    #[tokio::test]
711    async fn test_channel_file_deduplication() {
712        let (manager, _temp) = create_test_managers().await;
713
714        let data = b"shared content";
715        let metadata = create_test_metadata("shared.txt");
716
717        // Upload to two different channels
718        let handle1 = manager
719            .upload_to_channel("channel:A", data, metadata.clone(), Some("user1"), None)
720            .await
721            .unwrap();
722
723        let handle2 = manager
724            .upload_to_channel("channel:B", data, metadata.clone(), Some("user2"), None)
725            .await
726            .unwrap();
727
728        // Same content = same file ID
729        assert_eq!(handle1.id, handle2.id);
730
731        // But channel associations are different
732        let files_a = manager.list_channel_files("channel:A").await.unwrap();
733        let files_b = manager.list_channel_files("channel:B").await.unwrap();
734
735        assert_eq!(files_a.len(), 1);
736        assert_eq!(files_b.len(), 1);
737    }
738
739    #[tokio::test]
740    async fn test_remove_from_channel() {
741        let (manager, _temp) = create_test_managers().await;
742
743        let data = b"removable content";
744        let metadata = create_test_metadata("remove_me.txt");
745
746        let handle = manager
747            .upload_to_channel("channel:X", data, metadata, Some("user1"), None)
748            .await
749            .unwrap();
750
751        // Remove from channel
752        let removed = manager
753            .remove_from_channel("channel:X", &handle.id)
754            .await
755            .unwrap();
756        assert!(removed);
757
758        // List should be empty
759        let files = manager.list_channel_files("channel:X").await.unwrap();
760        assert!(files.is_empty());
761    }
762
763    #[tokio::test]
764    async fn test_delete_channel_cleanup() {
765        let (manager, _temp) = create_test_managers().await;
766
767        let data = b"cleanup content";
768        let metadata = create_test_metadata("cleanup.txt");
769
770        let _handle = manager
771            .upload_to_channel("cleanup_channel", data, metadata, Some("user1"), None)
772            .await
773            .unwrap();
774
775        // Delete channel with cleanup=true
776        let deleted = manager
777            .delete_channel("cleanup_channel", true)
778            .await
779            .unwrap();
780        assert_eq!(deleted, 1);
781
782        // File should be soft-deleted (exists but not in normal list)
783        let files = manager.list_channel_files("cleanup_channel").await.unwrap();
784        assert!(files.is_empty());
785    }
786
787    #[tokio::test]
788    async fn test_list_file_channels() {
789        let (manager, _temp) = create_test_managers().await;
790
791        let data = b"multi-channel file";
792        let metadata = create_test_metadata("multi.txt");
793
794        let handle = manager
795            .upload_to_channel("ch:A", data, metadata, Some("user1"), None)
796            .await
797            .unwrap();
798
799        // Add to another channel
800        manager
801            .add_file_to_channel("ch:B", &handle.id, Some("user2"), None)
802            .await
803            .unwrap();
804
805        // List all channels for this file
806        let channels = manager.list_file_channels(&handle.id).await.unwrap();
807        assert!(channels.contains(&"ch:A".to_string()));
808        assert!(channels.contains(&"ch:B".to_string()));
809    }
810
811    #[tokio::test]
812    async fn test_channel_stats() {
813        let (manager, _temp) = create_test_managers().await;
814
815        // Upload multiple files to a channel
816        for i in 0..3 {
817            let data = format!("content {}", i);
818            let metadata = create_test_metadata(&format!("file{}.txt", i));
819            manager
820                .upload_to_channel(
821                    "stats_channel",
822                    data.as_bytes(),
823                    metadata,
824                    Some("user1"),
825                    None,
826                )
827                .await
828                .unwrap();
829        }
830
831        let stats = manager.channel_stats("stats_channel").await.unwrap();
832        assert_eq!(stats.total_files, 3);
833    }
834}