Skip to content

Commit 191ac18

Browse files
committed
improve archive config file priority
1 parent c56d4db commit 191ac18

3 files changed

Lines changed: 170 additions & 72 deletions

File tree

src/addons/archive/README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,27 @@ This addon provides functionality to archive old call records to compressed CSV
44

55
## Configuration
66

7-
Add the following section to your `config.toml` (or `rustpbx.toml`):
7+
Enable the addon in your main `config.toml` (or `rustpbx.toml`):
8+
9+
```toml
10+
[proxy]
11+
addons = ["archive"]
12+
```
13+
14+
Then add the following settings to `archive.toml` in the same directory as your main config file:
815

916
```toml
10-
[archive]
1117
enabled = true
1218
archive_time = "03:00" # Time to run the archive job (HH:MM)
1319
timezone = "Asia/Shanghai" # Timezone for the schedule
1420
retention_days = 30 # Keep data for 30 days
21+
archive_after_days = 0 # 0 archives yesterday's records
22+
# archive_dir = "/var/lib/rustpbx/archive"
1523
```
1624

25+
For compatibility, the addon also accepts the same settings under `[archive]` in the main config
26+
when `archive.toml` is not present. The console UI saves archive settings to `archive.toml`.
27+
1728
## Features
1829

1930
- **Scheduled Archiving**: Automatically runs daily at the configured time.

src/addons/archive/handlers.rs

Lines changed: 23 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ use chrono::NaiveDate;
1010
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter};
1111
use serde::{Deserialize, Serialize};
1212
use std::sync::{Arc, RwLock};
13-
use toml_edit::{DocumentMut, value};
1413
use tracing::{error, info};
1514
use uuid::Uuid;
1615

@@ -136,11 +135,6 @@ pub async fn update_config(
136135
Extension(archive_state): Extension<ArchiveState>,
137136
Json(payload): Json<UpdateConfigPayload>,
138137
) -> impl IntoResponse {
139-
let config_path = state
140-
.config_path
141-
.clone()
142-
.unwrap_or_else(|| "config.toml".to_string());
143-
144138
// Validate timezone before touching the config file
145139
let tz_str = payload.timezone.trim();
146140
if tz_str.parse::<chrono_tz::Tz>().is_err() {
@@ -156,62 +150,38 @@ pub async fn update_config(
156150
).into_response();
157151
}
158152

159-
let res = (|| -> anyhow::Result<()> {
160-
let config_content = std::fs::read_to_string(&config_path)?;
161-
let mut doc = config_content.parse::<DocumentMut>()?;
153+
let archive_config_path = super::archive_config_path(&state.config_path);
154+
let new_archive_dir = payload
155+
.archive_dir
156+
.as_deref()
157+
.map(|s| s.trim().to_string())
158+
.filter(|s| !s.is_empty());
159+
let new_config = crate::addons::archive::ArchiveConfig {
160+
enabled: payload.enabled,
161+
archive_time: payload.archive_time.trim().to_string(),
162+
timezone: Some(tz_str.to_string()),
163+
retention_days: payload.retention_days,
164+
archive_after_days: payload.archive_after_days,
165+
archive_dir: new_archive_dir,
166+
};
162167

163-
let needs_archive_init = doc
164-
.as_table()
165-
.get("archive")
166-
.map(|item| !item.is_table())
167-
.unwrap_or(true);
168-
if needs_archive_init {
169-
doc.insert("archive", toml_edit::table());
168+
let res = (|| -> anyhow::Result<()> {
169+
if let Some(parent) = archive_config_path.parent()
170+
&& !parent.as_os_str().is_empty()
171+
{
172+
std::fs::create_dir_all(parent)?;
170173
}
171174

172-
let archive_table = doc
173-
.as_table_mut()
174-
.get_mut("archive")
175-
.and_then(toml_edit::Item::as_table_mut)
176-
.ok_or_else(|| anyhow::anyhow!("[archive] must be a table"))?;
177-
178-
archive_table["enabled"] = value(payload.enabled);
179-
archive_table["archive_time"] = value(payload.archive_time.trim());
180-
archive_table["timezone"] = value(tz_str);
181-
archive_table["retention_days"] = value(payload.retention_days as i64);
182-
archive_table["archive_after_days"] = value(payload.archive_after_days as i64);
183-
match payload.archive_dir.as_deref() {
184-
Some(d) if !d.trim().is_empty() => {
185-
archive_table["archive_dir"] = value(d.trim());
186-
}
187-
_ => {
188-
// Remove override → fall back to derived default
189-
archive_table.remove("archive_dir");
190-
}
191-
}
192-
std::fs::write(&config_path, doc.to_string())?;
193-
info!("Updated archive config in {}", config_path);
175+
let config_content = toml::to_string_pretty(&new_config)?;
176+
std::fs::write(&archive_config_path, config_content)?;
177+
info!("Updated archive config in {}", archive_config_path.display());
194178
Ok(())
195179
})();
196180

197181
match res {
198182
Ok(_) => {
199183
// Update in-memory config
200-
let tz_str = tz_str.to_string();
201-
let mut config_guard = archive_state.config.write().unwrap();
202-
let new_archive_dir = payload
203-
.archive_dir
204-
.as_deref()
205-
.map(|s| s.trim().to_string())
206-
.filter(|s| !s.is_empty());
207-
*config_guard = Some(crate::addons::archive::ArchiveConfig {
208-
enabled: payload.enabled,
209-
archive_time: payload.archive_time.trim().to_string(),
210-
timezone: Some(tz_str.to_string()),
211-
retention_days: payload.retention_days,
212-
archive_after_days: payload.archive_after_days,
213-
archive_dir: new_archive_dir,
214-
});
184+
*archive_state.config.write().unwrap() = Some(new_config);
215185
Json(serde_json::json!({"success": true})).into_response()
216186
}
217187
Err(e) => {

src/addons/archive/mod.rs

Lines changed: 134 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use chrono::{DateTime, Duration, NaiveDate, NaiveTime, Utc};
99
use chrono_tz::Tz;
1010
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
1111
use std::collections::HashMap;
12+
use std::path::PathBuf;
1213
use std::sync::{Arc, RwLock};
1314
use tokio::time;
1415
use tracing::{error, info};
@@ -39,12 +40,95 @@ pub struct ArchiveAddon {
3940
state: ArchiveState,
4041
}
4142

43+
fn archive_config_path(config_path: &Option<String>) -> PathBuf {
44+
config_path
45+
.as_deref()
46+
.and_then(|path| std::path::Path::new(path).parent())
47+
.map(|dir| dir.join("archive.toml"))
48+
.unwrap_or_else(|| PathBuf::from("archive.toml"))
49+
}
50+
4251
impl Default for ArchiveAddon {
4352
fn default() -> Self {
4453
Self::new()
4554
}
4655
}
4756

57+
#[cfg(test)]
58+
mod tests {
59+
use super::*;
60+
61+
#[test]
62+
fn archive_config_path_uses_main_config_directory() {
63+
let path = Some("config/rustpbx.toml".to_string());
64+
65+
assert_eq!(
66+
archive_config_path(&path),
67+
std::path::PathBuf::from("config/archive.toml")
68+
);
69+
}
70+
71+
#[tokio::test]
72+
async fn load_config_reads_archive_toml_next_to_main_config() {
73+
let dir = tempfile::tempdir().unwrap();
74+
let main_config = dir.path().join("rustpbx.toml");
75+
let archive_config = dir.path().join("archive.toml");
76+
std::fs::write(&main_config, "[proxy]\naddons = [\"archive\"]\n").unwrap();
77+
std::fs::write(
78+
&archive_config,
79+
r#"enabled = true
80+
archive_time = "03:00"
81+
timezone = "Asia/Shanghai"
82+
retention_days = 30
83+
archive_after_days = 7
84+
archive_dir = "/tmp/rustpbx-archive"
85+
"#,
86+
)
87+
.unwrap();
88+
89+
let loaded =
90+
ArchiveAddon::load_config(&Some(main_config.to_string_lossy().to_string())).await;
91+
let loaded = loaded.expect("archive config should load");
92+
93+
assert!(loaded.enabled);
94+
assert_eq!(loaded.archive_time, "03:00");
95+
assert_eq!(loaded.timezone.as_deref(), Some("Asia/Shanghai"));
96+
assert_eq!(loaded.retention_days, 30);
97+
assert_eq!(loaded.archive_after_days, 7);
98+
assert_eq!(loaded.archive_dir.as_deref(), Some("/tmp/rustpbx-archive"));
99+
}
100+
101+
#[tokio::test]
102+
async fn load_config_falls_back_to_archive_table_in_main_config() {
103+
let dir = tempfile::tempdir().unwrap();
104+
let main_config = dir.path().join("rustpbx.toml");
105+
std::fs::write(
106+
&main_config,
107+
r#"[proxy]
108+
addons = ["archive"]
109+
110+
[archive]
111+
enabled = true
112+
archive_time = "04:00"
113+
timezone = "UTC"
114+
retention_days = 14
115+
archive_after_days = 3
116+
"#,
117+
)
118+
.unwrap();
119+
120+
let loaded =
121+
ArchiveAddon::load_config(&Some(main_config.to_string_lossy().to_string())).await;
122+
let loaded = loaded.expect("archive config should load from main config");
123+
124+
assert!(loaded.enabled);
125+
assert_eq!(loaded.archive_time, "04:00");
126+
assert_eq!(loaded.timezone.as_deref(), Some("UTC"));
127+
assert_eq!(loaded.retention_days, 14);
128+
assert_eq!(loaded.archive_after_days, 3);
129+
}
130+
}
131+
48132
impl ArchiveAddon {
49133
pub fn new() -> Self {
50134
Self {
@@ -58,30 +142,63 @@ impl ArchiveAddon {
58142

59143
/// Load configuration from addon-specific config file.
60144
pub async fn load_config(config_path: &Option<String>) -> Option<ArchiveConfig> {
145+
let addon_config_path = archive_config_path(config_path);
146+
if addon_config_path.exists() {
147+
match tokio::fs::read_to_string(&addon_config_path).await {
148+
Ok(content) => match toml::from_str::<ArchiveConfig>(&content) {
149+
Ok(config) => {
150+
tracing::info!(
151+
"Archive config loaded from {}",
152+
addon_config_path.display()
153+
);
154+
return Some(config);
155+
}
156+
Err(e) => {
157+
tracing::warn!("Failed to parse archive.toml: {}", e);
158+
}
159+
},
160+
Err(e) => {
161+
tracing::warn!("Failed to read archive.toml: {}", e);
162+
}
163+
}
164+
}
165+
61166
if let Some(path) = config_path {
62-
let config_dir = std::path::Path::new(path).parent()?;
63-
let addon_config_path = config_dir.join("archive.toml");
64-
if addon_config_path.exists() {
65-
match tokio::fs::read_to_string(&addon_config_path).await {
66-
Ok(content) => match toml::from_str::<ArchiveConfig>(&content) {
67-
Ok(config) => {
68-
tracing::info!(
69-
"Archive config loaded from {}",
70-
addon_config_path.display()
71-
);
72-
return Some(config);
73-
}
74-
Err(e) => {
75-
tracing::warn!("Failed to parse archive.toml: {}", e);
167+
match tokio::fs::read_to_string(path).await {
168+
Ok(content) => match toml::from_str::<toml::Value>(&content) {
169+
Ok(value) => {
170+
if let Some(archive_value) = value.get("archive") {
171+
match archive_value.clone().try_into::<ArchiveConfig>() {
172+
Ok(config) => {
173+
tracing::info!(
174+
"Archive config loaded from [archive] in {}",
175+
path
176+
);
177+
return Some(config);
178+
}
179+
Err(e) => {
180+
tracing::warn!(
181+
"Failed to parse [archive] from {}: {}",
182+
path,
183+
e
184+
);
185+
}
186+
}
76187
}
77-
},
188+
}
78189
Err(e) => {
79-
tracing::warn!("Failed to read archive.toml: {}", e);
190+
tracing::warn!("Failed to parse {} while checking [archive]: {}", path, e);
80191
}
192+
},
193+
Err(e) => {
194+
tracing::warn!("Failed to read {} while checking [archive]: {}", path, e);
81195
}
82196
}
83197
}
84-
tracing::info!("Archive using default configuration (no archive.toml found)");
198+
199+
tracing::info!(
200+
"Archive using default configuration (no archive.toml or [archive] config found)"
201+
);
85202
None
86203
}
87204

0 commit comments

Comments
 (0)