Skip to main content

leenfetch_core/modules/linux/system/
distro.rs

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