11//! 音频落盘策略配置(REQ-068 / v0.6.0 M4 的**配置面**;批 8 T27 从 `audio_store.rs` 抽出)。
22//!
3- //! @ai-context: 本件只承载**类型与默认值**(结构体 + `Default` + 两个 `DEFAULT_*`)
4- //! —— 与 WAV 写侧(`audio_store.rs` 的 `SessionAudioWriter` / `cleanup`)
5- //! 物理分离:配策略与写字节是两件事,混在一处会让"改默认值"与"改写盘逻辑"
6- //! 在同一文件里互相牵动(AGENTS.md §3「纯逻辑与副作用物理分离」)。
7- //! @ai-context: 由 `audio_store.rs` 以 `#[path]` 声明(同 `audio_align` 先例)⇒ `lib.rs`
3+ //! @ai-context: 产品问题 #1(用户裁决 U1-a #1):面板显示的「落盘启用状态」用户改不了 ——
4+ //! `AudioStoreConfig` 此前**只有 `Default`**(无 JSON / env 通道)⇒ `status.enabled`
5+ //! 恒 `true`。本件补齐持久化通道,把开关做成真的。
6+ //! @ai-context: 形态**逐条对齐同域先例** `audio_preproc_config.rs`:`load`(缺失 / 损坏 ⇒
7+ //! 默认 + 可观测日志,不阻断会话)/ `save`(原子写 = `.tmp` → rename)/
8+ //! `effective`(配置文件 > env > 默认)。**唯一形态差异**:本配置有 3 个字段而
9+ //! env 只管总开关 ⇒ `effective()` 返回生效后的 `Self`(先例是单字段配置、返回
10+ //! `bool`;若此处也返回 `bool`,调用点得手工拼结构体才能交给
11+ //! `SessionAudioWriter::create`)。
12+ //! @ai-context: 读取优先级:`{data_dir}/audio-store.json` > env `ENTROPY_AUDIO_STORE`
13+ //! (开发期快速实测)> 默认(开 / 保留 30 天 / 预算 4GB)。
14+ //! @ai-context: 副作用 = 读写**应用数据目录**内的单个 JSON(AGENTS.md §4:文件系统访问限定
15+ //! 应用数据目录);`save` 先写 `.tmp` 再 rename(防写一半损坏配置)。
16+ //! @ai-context: 边界 = ① 配置**不进 SQLite** ⇒ 零迁移(`db_migrations.rs` 零改动);② 开关只在
17+ //! **下次实时会话**生效(不热切换进行中会话 —— 音频链路抖动风险,同
18+ //! audio-preproc.json 先例);③ 本件**不改**音频存储格式(WAV 仍 16kHz 单声道
19+ //! PCM16),只改「是否存储」;④ 任何失败都不阻断会话主链路(音频是增强数据源)。
20+ //! @ai-context: 本件由 `audio_store.rs` 以 `#[path]` 声明(同 `audio_align` 先例)⇒ `lib.rs`
821//! 一行未动;`AudioStoreConfig` 与两个 `DEFAULT_*` 仍从 `audio_store` 原路径
922//! 再导出 ⇒ 既有调用点零改动。
10- //! @ai-context: 本步是**行为等价的纯搬家**:一个调用点也不改(配置通道是下一步)。
11- //! @ai-context: 边界 = 本件不改音频存储格式(WAV 仍 16kHz 单声道 PCM16),也没有任何 IO。
23+
24+ use serde :: { Deserialize , Serialize } ;
1225
1326/// 默认保留期(天)。
1427pub const DEFAULT_RETENTION_DAYS : u64 = 30 ;
1528/// 默认磁盘预算(字节;1 小时 ≈ 115MB,预算 4GB ≈ 35 小时会话)。
1629pub const DEFAULT_DISK_BUDGET_BYTES : u64 = 4 * 1024 * 1024 * 1024 ;
1730
18- /// 落盘策略配置。
19- #[ derive( Debug , Clone , Copy , PartialEq ) ]
31+ /// 落盘策略配置(JSON;应用数据目录 `audio-store.json`) 。
32+ #[ derive( Debug , Clone , Copy , PartialEq , Serialize , Deserialize ) ]
2033pub struct AudioStoreConfig {
2134 /// 总开关(默认开;关闭=现状行为零开销)
2235 pub enabled : bool ,
@@ -26,6 +39,49 @@ pub struct AudioStoreConfig {
2639 pub disk_budget_bytes : u64 ,
2740}
2841
42+ impl AudioStoreConfig {
43+ /// 从 JSON 文件加载(缺失 / 损坏 → 默认值 + 可观测日志,不阻断启动)。
44+ ///
45+ /// @ai-context: 「损坏」= serde 解析失败(截断 / 手改错)。缺失与损坏**同档**回退默认 ——
46+ /// 配置读不出来不该让会话起不来(同 audio-preproc.json 先例)。
47+ pub fn load ( path : & std:: path:: Path ) -> Self {
48+ match std:: fs:: read_to_string ( path) {
49+ Ok ( json) => serde_json:: from_str ( & json) . unwrap_or_else ( |e| {
50+ eprintln ! ( "[AudioStore] 配置解析失败(回退默认值): {}" , e) ;
51+ Self :: default ( )
52+ } ) ,
53+ Err ( _) => Self :: default ( ) ,
54+ }
55+ }
56+
57+ /// 原子写配置(先写临时文件再 rename——防写一半损坏配置)。
58+ ///
59+ /// @ai-context: `parent` 不存在则创建(首启时该级目录可能还没有);临时名走
60+ /// `with_extension` ⇒ `audio-store.tmp`(与先例同形)。
61+ pub fn save ( & self , path : & std:: path:: Path ) -> std:: io:: Result < ( ) > {
62+ let json = serde_json:: to_string_pretty ( self )
63+ . map_err ( |e| std:: io:: Error :: new ( std:: io:: ErrorKind :: InvalidData , e. to_string ( ) ) ) ?;
64+ if let Some ( parent) = path. parent ( ) {
65+ std:: fs:: create_dir_all ( parent) ?;
66+ }
67+ let tmp = path. with_extension ( "tmp" ) ;
68+ std:: fs:: write ( & tmp, json) ?;
69+ std:: fs:: rename ( & tmp, path) ?;
70+ Ok ( ( ) )
71+ }
72+
73+ /// 生效配置(配置文件 > env `ENTROPY_AUDIO_STORE` > 默认)。
74+ ///
75+ /// @ai-context: env 只覆盖**总开关**(`=1` 开、其余关 —— 与先例 `ENTROPY_AUDIO_PREPROC`
76+ /// 逐字同形);保留期 / 预算无 env 通道(它们不在产品问题 #1 的面内)。
77+ pub fn effective ( & self ) -> Self {
78+ match std:: env:: var ( "ENTROPY_AUDIO_STORE" ) {
79+ Ok ( v) => Self { enabled : v == "1" , ..* self } ,
80+ Err ( _) => * self ,
81+ }
82+ }
83+ }
84+
2985/// 默认配置:开启(保留 30 天 / 预算 4GB;关闭 = 现状行为零开销)。
3086impl Default for AudioStoreConfig {
3187 fn default ( ) -> Self {
@@ -36,3 +92,121 @@ impl Default for AudioStoreConfig {
3692 }
3793 }
3894}
95+
96+ #[ cfg( test) ]
97+ mod tests {
98+ use super :: * ;
99+
100+ /// 独立临时目录夹具(pid + tag ⇒ 并发用例互不踩;Drop 时整体删除)。
101+ struct TempDir {
102+ root : std:: path:: PathBuf ,
103+ }
104+
105+ impl TempDir {
106+ fn new ( tag : & str ) -> Self {
107+ let root = std:: env:: temp_dir ( )
108+ . join ( format ! ( "entropy-audio-store-{}-{}" , std:: process:: id( ) , tag) ) ;
109+ std:: fs:: create_dir_all ( & root) . expect ( "建临时目录" ) ;
110+ Self { root }
111+ }
112+
113+ fn cfg ( & self ) -> std:: path:: PathBuf {
114+ self . root . join ( "audio-store.json" )
115+ }
116+ }
117+
118+ impl Drop for TempDir {
119+ fn drop ( & mut self ) {
120+ let _ = std:: fs:: remove_dir_all ( & self . root ) ;
121+ }
122+ }
123+
124+ #[ test]
125+ fn default_is_enabled_with_frozen_policy ( ) {
126+ // 默认:开 + 保留 30 天 + 预算 4GB(策略默认值与原 audio_store 常量同源)
127+ let cfg = AudioStoreConfig :: default ( ) ;
128+ assert ! ( cfg. enabled) ;
129+ assert_eq ! ( cfg. retention_days, DEFAULT_RETENTION_DAYS ) ;
130+ assert_eq ! ( cfg. disk_budget_bytes, DEFAULT_DISK_BUDGET_BYTES ) ;
131+ }
132+
133+ #[ test]
134+ fn load_missing_file_defaults ( ) {
135+ // 缺失配置 → 默认开(不阻断启动)
136+ let cfg = AudioStoreConfig :: load ( std:: path:: Path :: new ( "C:/nonexistent/audio-store.json" ) ) ;
137+ assert_eq ! ( cfg, AudioStoreConfig :: default ( ) ) ;
138+ }
139+
140+ #[ test]
141+ fn save_then_load_roundtrip ( ) {
142+ // Arrange:非默认的策略值(证明三个字段都真的落盘,而非只落了开关)
143+ let d = TempDir :: new ( "roundtrip" ) ;
144+ let cfg = AudioStoreConfig { enabled : false , retention_days : 7 , disk_budget_bytes : 1_024 } ;
145+ // Act
146+ cfg. save ( & d. cfg ( ) ) . expect ( "保存成功" ) ;
147+ let loaded = AudioStoreConfig :: load ( & d. cfg ( ) ) ;
148+ // Assert:读回逐字段相等(roundtrip)
149+ assert_eq ! ( loaded, cfg) ;
150+ }
151+
152+ #[ test]
153+ fn corrupted_json_falls_back_without_panic ( ) {
154+ // 损坏 JSON → 默认值(防御:配置错误不劣化音频链路;**不 panic**)
155+ let d = TempDir :: new ( "corrupt" ) ;
156+ std:: fs:: write ( d. cfg ( ) , b"{ not json" ) . unwrap ( ) ;
157+ assert_eq ! ( AudioStoreConfig :: load( & d. cfg( ) ) , AudioStoreConfig :: default ( ) ) ;
158+ }
159+
160+ #[ test]
161+ fn user_toggle_persists_disabled ( ) {
162+ // 🔴 V1 核心:用户关闭 ⇒ 落盘 ⇒ **重新 load 同一文件**仍为 false(默认开不覆盖用户选择)
163+ let d = TempDir :: new ( "off" ) ;
164+ let saved = AudioStoreConfig { enabled : false , ..AudioStoreConfig :: default ( ) } ;
165+ saved. save ( & d. cfg ( ) ) . expect ( "保存成功" ) ;
166+ // 重新载入(模拟下一次会话/命令调用;**不是**重启真机)
167+ let reloaded = AudioStoreConfig :: load ( & d. cfg ( ) ) ;
168+ assert ! ( !reloaded. enabled, "配置文件显式 false 应保持关闭" ) ;
169+ assert_eq ! ( reloaded, saved, "保存后读回 == 保存值" ) ;
170+ }
171+
172+ #[ test]
173+ fn save_is_idempotent ( ) {
174+ // 幂等:连存两次 == 存一次(原子写的字节确定)
175+ let d = TempDir :: new ( "idem" ) ;
176+ let cfg = AudioStoreConfig { enabled : false , retention_days : 7 , disk_budget_bytes : 1_024 } ;
177+ cfg. save ( & d. cfg ( ) ) . expect ( "第一次保存" ) ;
178+ let once = std:: fs:: read ( d. cfg ( ) ) . unwrap ( ) ;
179+ cfg. save ( & d. cfg ( ) ) . expect ( "第二次保存" ) ;
180+ let twice = std:: fs:: read ( d. cfg ( ) ) . unwrap ( ) ;
181+ assert_eq ! ( twice, once, "连存两次应与存一次逐字节相同" ) ;
182+ // 且临时文件不残留(rename 而非 copy)
183+ assert ! ( !d. root. join( "audio-store.tmp" ) . exists( ) ) ;
184+ }
185+
186+ #[ test]
187+ fn env_overrides_config_switch ( ) {
188+ // env 开发期通道优先于配置文件(快速实测不落盘)
189+ unsafe {
190+ std:: env:: set_var ( "ENTROPY_AUDIO_STORE" , "1" ) ;
191+ }
192+ let cfg = AudioStoreConfig { enabled : false , ..AudioStoreConfig :: default ( ) } ;
193+ assert ! ( cfg. effective( ) . enabled, "env=1 应覆盖配置文件关闭态" ) ;
194+ unsafe {
195+ std:: env:: set_var ( "ENTROPY_AUDIO_STORE" , "0" ) ;
196+ }
197+ assert ! ( !cfg. effective( ) . enabled, "env=0 应保持关闭" ) ;
198+ unsafe {
199+ std:: env:: remove_var ( "ENTROPY_AUDIO_STORE" ) ;
200+ }
201+ assert ! ( !cfg. effective( ) . enabled, "无 env 时回退配置文件" ) ;
202+ }
203+
204+ #[ test]
205+ fn effective_without_env_keeps_policy_fields ( ) {
206+ // env 只管总开关:保留期 / 预算在任何 env 下都原样透传(无 env 通道)
207+ let cfg = AudioStoreConfig { enabled : true , retention_days : 3 , disk_budget_bytes : 512 } ;
208+ let eff = cfg. effective ( ) ;
209+ assert_eq ! ( eff. retention_days, 3 ) ;
210+ assert_eq ! ( eff. disk_budget_bytes, 512 ) ;
211+ }
212+ }
0 commit comments