Skip to content

Commit 5cddc26

Browse files
committed
refactor: improve error handling and simplify codebase
Updated the configuration loading to use explicit error results rather than silent failures. Replaced silent filtering of invalid configuration items with descriptive errors and performed a code-wide cleanup by utilizing pattern guards in key-handling logic and implementing atomic file writing for state files. Signed-off-by: Chmouel Boudjnah <chmouel@chmouel.com>
1 parent 82d368f commit 5cddc26

9 files changed

Lines changed: 208 additions & 143 deletions

File tree

src/config.rs

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,8 @@ impl Config {
268268
/// Load configuration from file and environment variables.
269269
/// Priority: CLI args > env vars > config file > defaults
270270
/// If config_path is provided, use it instead of the default path.
271-
pub fn load(config_path: Option<&Path>) -> Self {
272-
let mut config = Self::load_from_file(config_path).unwrap_or_default();
271+
pub fn load(config_path: Option<&Path>) -> Result<Self> {
272+
let mut config = Self::load_from_file(config_path)?.unwrap_or_default();
273273

274274
// Environment variable overrides for backwards compatibility
275275
if let Ok(interval) = env::var("GH_NEWS_AUTO_REFRESH_INTERVAL") {
@@ -278,7 +278,7 @@ impl Config {
278278
}
279279
}
280280

281-
config
281+
Ok(config)
282282
}
283283

284284
/// Get the default config file path (~/.config/gh-news/config.toml).
@@ -291,19 +291,40 @@ impl Config {
291291

292292
/// Load configuration from the TOML config file.
293293
/// If config_path is provided, use it; otherwise use the default path.
294-
fn load_from_file(config_path: Option<&Path>) -> Option<Self> {
294+
fn load_from_file(config_path: Option<&Path>) -> Result<Option<Self>> {
295295
let path = match config_path {
296296
Some(p) => p.to_path_buf(),
297-
None => Self::get_config_path().ok()?,
297+
None => match Self::get_config_path() {
298+
Ok(path) => path,
299+
Err(_) => return Ok(None),
300+
},
298301
};
299302

300303
if !path.exists() {
301-
return None;
304+
if config_path.is_some() {
305+
return Err(Error::Config(format!(
306+
"Config file not found: {}",
307+
path.display()
308+
)));
309+
}
310+
return Ok(None);
302311
}
303312

304-
let content = fs::read_to_string(&path).ok()?;
305-
let config: Config = toml::from_str(&content).ok()?;
306-
Some(config)
313+
let content = fs::read_to_string(&path).map_err(|e| {
314+
Error::Config(format!(
315+
"Failed to read config file {}: {}",
316+
path.display(),
317+
e
318+
))
319+
})?;
320+
let config: Config = toml::from_str(&content).map_err(|e| {
321+
Error::Config(format!(
322+
"Failed to parse config file {}: {}",
323+
path.display(),
324+
e
325+
))
326+
})?;
327+
Ok(Some(config))
307328
}
308329

309330
/// Build the colour palette from the configured theme name and any overrides.
@@ -337,6 +358,7 @@ impl Config {
337358
#[cfg(test)]
338359
mod tests {
339360
use super::*;
361+
use std::fs;
340362

341363
#[test]
342364
fn test_default_config() {
@@ -549,4 +571,34 @@ red = "#112233"
549571
let default = crate::ui::theme::ColorPalette::tokyo_night();
550572
assert_eq!(palette.blue, default.blue);
551573
}
574+
575+
#[test]
576+
fn test_load_reports_missing_explicit_config() {
577+
let missing = std::env::temp_dir().join(format!(
578+
"gh-news-missing-config-{}-{}.toml",
579+
std::process::id(),
580+
"missing"
581+
));
582+
let err = Config::load(Some(&missing)).unwrap_err();
583+
assert!(err
584+
.to_string()
585+
.contains(&format!("Config file not found: {}", missing.display())));
586+
}
587+
588+
#[test]
589+
fn test_load_reports_invalid_config() {
590+
let path = std::env::temp_dir().join(format!(
591+
"gh-news-invalid-config-{}-{}.toml",
592+
std::process::id(),
593+
"parse"
594+
));
595+
fs::write(&path, "auto_refresh_interval = [").unwrap();
596+
597+
let err = Config::load(Some(&path)).unwrap_err();
598+
assert!(err
599+
.to_string()
600+
.contains(&format!("Failed to parse config file {}", path.display())));
601+
602+
let _ = fs::remove_file(path);
603+
}
552604
}

src/events.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ pub fn fetch_watch_repo_events(
133133
}
134134
}
135135

136-
all_notifications.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
136+
all_notifications.sort_by_key(|n| std::cmp::Reverse(n.updated_at));
137137

138138
enrich_event_titles(client, &mut all_notifications);
139139

src/filter.rs

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::config::{Config, View};
2-
use crate::error::Result;
2+
use crate::error::{Error, Result};
33
use crate::models::enums::{NotificationReason, NotificationType};
44
use crate::models::Notification;
55
use regex::Regex;
@@ -52,37 +52,41 @@ impl Filter {
5252
) -> Result<Self> {
5353
let parsed_types: Vec<NotificationType> = exclude_types
5454
.iter()
55-
.filter_map(|s| {
56-
let t: NotificationType = s.parse().ok()?;
57-
if t == NotificationType::Unknown {
58-
None
55+
.map(|s| {
56+
let parsed: NotificationType = s.parse().unwrap_or(NotificationType::Unknown);
57+
if parsed == NotificationType::Unknown {
58+
Err(Error::Config(format!(
59+
"Unknown notification type in exclude_types: {s}"
60+
)))
5961
} else {
60-
Some(t)
62+
Ok(parsed)
6163
}
6264
})
63-
.collect();
65+
.collect::<Result<_>>()?;
6466

6567
let parsed_reasons: Vec<NotificationReason> = exclude_reasons
6668
.iter()
67-
.filter_map(|s| {
68-
let r: NotificationReason = s.parse().ok()?;
69-
if r == NotificationReason::Unknown {
70-
None
69+
.map(|s| {
70+
let parsed: NotificationReason = s.parse().unwrap_or(NotificationReason::Unknown);
71+
if parsed == NotificationReason::Unknown {
72+
Err(Error::Config(format!(
73+
"Unknown notification reason in exclude_reasons: {s}"
74+
)))
7175
} else {
72-
Some(r)
76+
Ok(parsed)
7377
}
7478
})
75-
.collect();
79+
.collect::<Result<_>>()?;
7680

7781
let parsed_repos: Vec<RepoPattern> = exclude_repos
7882
.iter()
79-
.filter_map(|s| RepoPattern::new(s).ok())
80-
.collect();
83+
.map(|s| RepoPattern::new(s))
84+
.collect::<Result<_>>()?;
8185

8286
let parsed_subjects: Vec<Regex> = exclude_subjects
8387
.iter()
84-
.filter_map(|s| Regex::new(&format!("(?i){s}")).ok())
85-
.collect();
88+
.map(|s| Regex::new(&format!("(?i){s}")).map_err(Error::from))
89+
.collect::<Result<_>>()?;
8690

8791
let mut patterns = Vec::new();
8892
if let Some(pattern) = pattern {
@@ -290,18 +294,19 @@ mod tests {
290294
}
291295

292296
#[test]
293-
fn test_unrecognised_type_ignored() {
294-
let filter = Filter::new(
295-
None,
296-
&["NonExistentType".to_string(), "Issue".to_string()],
297-
&[],
298-
&[],
299-
&[],
300-
)
301-
.unwrap();
302-
// Only Issue should be excluded, unrecognised type silently ignored
303-
assert_eq!(filter.exclude_types.len(), 1);
304-
assert_eq!(filter.exclude_types[0], NotificationType::Issue);
297+
fn test_unrecognised_type_returns_error() {
298+
let err = Filter::new(None, &["NonExistentType".to_string()], &[], &[], &[]).unwrap_err();
299+
assert!(err
300+
.to_string()
301+
.contains("Unknown notification type in exclude_types: NonExistentType"));
302+
}
303+
304+
#[test]
305+
fn test_unrecognised_reason_returns_error() {
306+
let err = Filter::new(None, &[], &["mystery".to_string()], &[], &[]).unwrap_err();
307+
assert!(err
308+
.to_string()
309+
.contains("Unknown notification reason in exclude_reasons: mystery"));
305310
}
306311

307312
#[test]
@@ -373,4 +378,10 @@ mod tests {
373378
assert!(!filter.matches(&lower));
374379
assert!(filter.matches(&normal));
375380
}
381+
382+
#[test]
383+
fn test_invalid_subject_regex_returns_error() {
384+
let err = Filter::new(None, &[], &[], &[], &["[".to_string()]).unwrap_err();
385+
assert!(err.to_string().contains("Filter error"));
386+
}
376387
}

src/main.rs

Lines changed: 15 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ impl RuntimeOptions {
6363

6464
fn run() -> Result<()> {
6565
let args = Args::parse();
66-
let config = Config::load(args.config.as_deref());
66+
let config = Config::load(args.config.as_deref())?;
6767
let opts = RuntimeOptions::from_args_and_config(&args, &config);
6868

6969
// Initialise state file path: CLI > config > default
@@ -139,38 +139,24 @@ fn run() -> Result<()> {
139139
app.start_auto_refresh(opts.show_all, opts.participating, opts.max_notifications);
140140

141141
// Apply filters
142-
let filter = opts
143-
.filter_pattern
144-
.as_deref()
145-
.map(|pattern| Filter::from_config(Some(pattern), &config))
146-
.transpose()?
147-
.or_else(|| {
148-
// Even without a regex pattern, apply structured excludes if configured
149-
if config.exclude_types.is_empty()
150-
&& config.exclude_reasons.is_empty()
151-
&& config.exclude_repos.is_empty()
152-
&& config.exclude_subjects.is_empty()
153-
{
154-
None
155-
} else {
156-
Filter::from_config(None, &config).ok()
157-
}
158-
});
142+
let filter = if let Some(pattern) = opts.filter_pattern.as_deref() {
143+
Some(Filter::from_config(Some(pattern), &config)?)
144+
} else if config.exclude_types.is_empty()
145+
&& config.exclude_reasons.is_empty()
146+
&& config.exclude_repos.is_empty()
147+
&& config.exclude_subjects.is_empty()
148+
{
149+
None
150+
} else {
151+
Some(Filter::from_config(None, &config)?)
152+
};
159153

160154
// Always use config's default preview mode on startup
161155
let preview_mode = config.get_default_preview_mode();
162156

163-
// Config disabling always wins; otherwise use the persisted toggle (m-key).
164-
let auto_mark_read = if !config.auto_mark_read {
165-
false
166-
} else {
167-
state_file::AppStateFile::load_auto_mark_read().unwrap_or(false)
168-
};
169-
let auto_archive = if !config.auto_archive {
170-
false
171-
} else {
172-
state_file::AppStateFile::load_auto_archive().unwrap_or(false)
173-
};
157+
let auto_mark_read =
158+
state_file::AppStateFile::load_auto_mark_read().unwrap_or(config.auto_mark_read);
159+
let auto_archive = config.auto_archive;
174160

175161
// Set initial values. `set_auto_archive` will correctly enforce `auto_mark_read` if needed.
176162
app.set_auto_mark_read(auto_mark_read);

src/state/tree.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ impl<'a> TreeBuilder<'a> {
129129
})
130130
.collect();
131131

132-
org_list.sort_by(|a, b| b.2.cmp(&a.2));
132+
org_list.sort_by_key(|o| std::cmp::Reverse(o.2));
133133

134134
for (org, indices, _) in org_list {
135135
let notification_count = indices.len();
@@ -198,7 +198,7 @@ impl<'a> TreeBuilder<'a> {
198198
})
199199
.collect();
200200

201-
repo_list.sort_by(|a, b| b.2.cmp(&a.2));
201+
repo_list.sort_by_key(|r| std::cmp::Reverse(r.2));
202202
repo_list
203203
}
204204

0 commit comments

Comments
 (0)