Skip to main content

leenfetch_core/modules/linux/system/
distro.rs

1#![allow(
2    clippy::collapsible_if,
3    clippy::manual_strip
4)]
5
6use std::fs;
7use std::path::Path;
8use std::process::Command;
9
10use crate::modules::enums::DistroDisplay;
11
12pub fn get_distro(format: DistroDisplay) -> String {
13    let release_files = [
14        "/etc/os-release",
15        "/usr/lib/os-release",
16        "/etc/lsb-release",
17        "/etc/openwrt_release",
18    ];
19
20    for path in &release_files {
21        if Path::new(path).exists() {
22            if let Ok(contents) = fs::read_to_string(path) {
23                return parse_distro_info(&contents, format);
24            }
25        }
26    }
27
28    // Fallback: try lsb_release
29    if let Ok(output) = Command::new("lsb_release").arg("-si").output() {
30        if let Ok(name) = String::from_utf8(output.stdout) {
31            let name = name.trim().to_string();
32            if !name.is_empty() {
33                return name;
34            }
35        }
36    }
37
38    // Fallback: try uname
39    if let Ok(output) = Command::new("uname").arg("-o").output() {
40        if let Ok(name) = String::from_utf8(output.stdout) {
41            let name = name.trim().to_string();
42            if !name.is_empty() {
43                return name;
44            }
45        }
46    }
47
48    "Unknown".into()
49}
50
51/// Returns the `ID_LIKE` value from `/etc/os-release`, if present.
52/// Used by the ASCII art fallback to show the parent distro's logo (e.g., Omarchy → Arch).
53pub fn get_id_like() -> Option<String> {
54    let contents = fs::read_to_string("/etc/os-release").ok()?;
55    for line in contents.lines() {
56        let line = line.trim();
57        if line.starts_with("ID_LIKE=") {
58            let val = trim_quotes(&line[8..]);
59            let first = val.split_whitespace().next()?.to_lowercase();
60            return Some(first);
61        }
62    }
63    None
64}
65
66fn parse_distro_info(contents: &str, format: DistroDisplay) -> String {
67    let mut name = None;
68    let mut version = None;
69    let mut pretty = None;
70    let mut description = None;
71    let mut codename = None;
72
73    for line in contents.lines() {
74        let line = line.trim();
75        if line.starts_with("NAME=") {
76            name = Some(trim_quotes(&line[5..]));
77        } else if line.starts_with("VERSION_ID=") {
78            version = Some(trim_quotes(&line[11..]));
79        } else if line.starts_with("PRETTY_NAME=") {
80            pretty = Some(trim_quotes(&line[12..]));
81        } else if line.starts_with("DISTRIB_DESCRIPTION=") {
82            description = Some(trim_quotes(&line[21..]));
83        } else if line.starts_with("VERSION_CODENAME=") {
84            codename = Some(trim_quotes(&line[17..]));
85        } else if line.starts_with("UBUNTU_CODENAME=") {
86            codename = Some(trim_quotes(&line[16..]));
87        } else if line.starts_with("TAILS_PRODUCT_NAME=") {
88            name = Some(trim_quotes(&line[20..]));
89        }
90    }
91
92    let name = name
93        .or_else(|| pretty.clone())
94        .or_else(|| description.clone())
95        .unwrap_or_else(|| "Unknown".to_string());
96
97    let version = version.or_else(|| {
98        description.as_ref().and_then(|desc| {
99            desc.split_whitespace()
100                .find(|s| {
101                    s.chars()
102                        .next()
103                        .map(|c| c.is_ascii_digit())
104                        .unwrap_or(false)
105                })
106                .map(|s| s.to_string())
107        })
108    });
109
110    let arch = std::env::consts::ARCH;
111    let model = infer_model(&name, &codename, &description);
112
113    match format {
114        DistroDisplay::Name => name,
115        DistroDisplay::NameVersion => format!("{name} {}", version.unwrap_or_default())
116            .trim()
117            .to_string(),
118        DistroDisplay::NameArch => format!("{name} {}", arch).to_string(),
119        DistroDisplay::NameModel => format!("{name} {}", model).trim().to_string(),
120        DistroDisplay::NameModelVersion => {
121            format!("{name} {} {}", model, version.unwrap_or_default())
122                .trim()
123                .to_string()
124        }
125        DistroDisplay::NameModelArch => format!("{name} {} {}", model, arch).trim().to_string(),
126        DistroDisplay::NameModelVersionArch => {
127            format!("{name} {} {} {}", model, version.unwrap_or_default(), arch)
128                .trim()
129                .to_string()
130        }
131    }
132}
133
134fn trim_quotes(s: &str) -> String {
135    s.trim_matches('"').to_string()
136}
137
138struct DistroModel {
139    keyword: &'static str,
140    model: &'static str,
141}
142
143static MODEL_HINTS: &[DistroModel] = &[
144    DistroModel {
145        keyword: "arch",
146        model: "Rolling",
147    },
148    DistroModel {
149        keyword: "artix",
150        model: "Rolling",
151    },
152    DistroModel {
153        keyword: "endeavouros",
154        model: "Rolling",
155    },
156    DistroModel {
157        keyword: "manjaro",
158        model: "Rolling",
159    },
160    DistroModel {
161        keyword: "void",
162        model: "Rolling",
163    },
164    DistroModel {
165        keyword: "nixos",
166        model: "Rolling",
167    },
168    DistroModel {
169        keyword: "tumbleweed",
170        model: "Rolling",
171    },
172    DistroModel {
173        keyword: "rawhide",
174        model: "Testing",
175    },
176    DistroModel {
177        keyword: "testing",
178        model: "Testing",
179    },
180    DistroModel {
181        keyword: "stable",
182        model: "Stable",
183    },
184    DistroModel {
185        keyword: "ubuntu",
186        model: "LTS",
187    }, // fallback for known Ubuntu LTS
188    DistroModel {
189        keyword: "lts",
190        model: "LTS",
191    },
192    DistroModel {
193        keyword: "tails",
194        model: "Stable",
195    },
196    DistroModel {
197        keyword: "alpine",
198        model: "Stable",
199    },
200    DistroModel {
201        keyword: "debian",
202        model: "Stable",
203    }, // default to Stable unless Testing is seen
204];
205
206fn infer_model(name: &str, codename: &Option<String>, description: &Option<String>) -> String {
207    let text = format!(
208        "{} {} {}",
209        name.to_lowercase(),
210        codename.as_deref().unwrap_or("").to_lowercase(),
211        description.as_deref().unwrap_or("").to_lowercase()
212    );
213
214    for entry in MODEL_HINTS {
215        if text.contains(entry.keyword) {
216            return entry.model.to_string();
217        }
218    }
219
220    "Unknown".into()
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::modules::enums::DistroDisplay;
227
228    fn sample_release() -> &'static str {
229        r#"NAME="ExampleOS"
230VERSION_ID="42"
231PRETTY_NAME="ExampleOS 42"
232VERSION_CODENAME="Aurora"
233DISTRIB_DESCRIPTION="ExampleOS 42 Aurora"
234"#
235    }
236
237    #[test]
238    fn parses_name_variants() {
239        let data = sample_release();
240        assert_eq!(parse_distro_info(data, DistroDisplay::Name), "ExampleOS");
241        assert_eq!(
242            parse_distro_info(data, DistroDisplay::NameVersion),
243            "ExampleOS 42"
244        );
245        assert!(
246            parse_distro_info(data, DistroDisplay::NameArch).contains("ExampleOS"),
247            "NameArch should include distro name"
248        );
249    }
250
251    #[test]
252    fn infers_model_from_hints() {
253        let desc = Some("Arch Linux Rolling".to_string());
254        let model = infer_model("Arch Linux", &None, &desc);
255        assert_eq!(model, "Rolling");
256
257        let codename = Some("jammy".to_string());
258        let model = infer_model("Ubuntu", &codename, &None);
259        assert_eq!(model, "LTS");
260    }
261}