Initial nimfetch implementation
e6e2cdc added
.gitignore +4 -0 | new file mode 100644 | ||
| @@ -0,0 +1,4 @@ | ||
| 1 | +nimfetch | |
| 2 | +bin/ | |
| 3 | +nimcache/ | |
| 4 | +tests/test_nimfetch | |
| new file mode 100644 | |||
| @@ -0,0 +1,4 @@ | |||
| 1 | +nimfetch | ||
| 2 | +bin/ | ||
| 3 | +nimcache/ | ||
| 4 | +tests/test_nimfetch | ||
added
LICENSE +21 -0 | new file mode 100644 | ||
| @@ -0,0 +1,21 @@ | ||
| 1 | +MIT License | |
| 2 | + | |
| 3 | +Copyright (c) 2026 nimfetch contributors | |
| 4 | + | |
| 5 | +Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | +of this software and associated documentation files (the "Software"), to deal | |
| 7 | +in the Software without restriction, including without limitation the rights | |
| 8 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | +copies of the Software, and to permit persons to whom the Software is | |
| 10 | +furnished to do so, subject to the following conditions: | |
| 11 | + | |
| 12 | +The above copyright notice and this permission notice shall be included in all | |
| 13 | +copies or substantial portions of the Software. | |
| 14 | + | |
| 15 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | +SOFTWARE. | |
| new file mode 100644 | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | +MIT License | ||
| 2 | + | ||
| 3 | +Copyright (c) 2026 nimfetch contributors | ||
| 4 | + | ||
| 5 | +Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| 6 | +of this software and associated documentation files (the "Software"), to deal | ||
| 7 | +in the Software without restriction, including without limitation the rights | ||
| 8 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| 9 | +copies of the Software, and to permit persons to whom the Software is | ||
| 10 | +furnished to do so, subject to the following conditions: | ||
| 11 | + | ||
| 12 | +The above copyright notice and this permission notice shall be included in all | ||
| 13 | +copies or substantial portions of the Software. | ||
| 14 | + | ||
| 15 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| 16 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| 17 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| 18 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| 19 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| 20 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| 21 | +SOFTWARE. | ||
added
README.md +34 -0 | new file mode 100644 | ||
| @@ -0,0 +1,34 @@ | ||
| 1 | +# nimfetch | |
| 2 | + | |
| 3 | +A small, fastfetch-inspired system information tool written in Nim. It has no | |
| 4 | +runtime dependencies and uses the normal system utilities available on the host. | |
| 5 | + | |
| 6 | +## Build | |
| 7 | + | |
| 8 | +```sh | |
| 9 | +nimble build -d:release | |
| 10 | +./nimfetch | |
| 11 | +``` | |
| 12 | + | |
| 13 | +Or compile directly: | |
| 14 | + | |
| 15 | +```sh | |
| 16 | +nim c -d:release -o:nimfetch src/nimfetch.nim | |
| 17 | +``` | |
| 18 | + | |
| 19 | +## Usage | |
| 20 | + | |
| 21 | +```text | |
| 22 | +nimfetch [--no-logo] [--logo arch|ubuntu|debian|fedora|linux] | |
| 23 | + [--no-color] [--json] [--version] [--help] | |
| 24 | +``` | |
| 25 | + | |
| 26 | +The implementation targets Linux first, with basic macOS and Windows fallbacks. | |
| 27 | +Missing optional commands such as `lspci`, `ip`, or a package manager are shown | |
| 28 | +as `Unknown` instead of causing the program to fail. | |
| 29 | + | |
| 30 | +## Test | |
| 31 | + | |
| 32 | +```sh | |
| 33 | +nimble test | |
| 34 | +``` | |
| new file mode 100644 | |||
| @@ -0,0 +1,34 @@ | |||
| 1 | +# nimfetch | ||
| 2 | + | ||
| 3 | +A small, fastfetch-inspired system information tool written in Nim. It has no | ||
| 4 | +runtime dependencies and uses the normal system utilities available on the host. | ||
| 5 | + | ||
| 6 | +## Build | ||
| 7 | + | ||
| 8 | +```sh | ||
| 9 | +nimble build -d:release | ||
| 10 | +./nimfetch | ||
| 11 | +``` | ||
| 12 | + | ||
| 13 | +Or compile directly: | ||
| 14 | + | ||
| 15 | +```sh | ||
| 16 | +nim c -d:release -o:nimfetch src/nimfetch.nim | ||
| 17 | +``` | ||
| 18 | + | ||
| 19 | +## Usage | ||
| 20 | + | ||
| 21 | +```text | ||
| 22 | +nimfetch [--no-logo] [--logo arch|ubuntu|debian|fedora|linux] | ||
| 23 | + [--no-color] [--json] [--version] [--help] | ||
| 24 | +``` | ||
| 25 | + | ||
| 26 | +The implementation targets Linux first, with basic macOS and Windows fallbacks. | ||
| 27 | +Missing optional commands such as `lspci`, `ip`, or a package manager are shown | ||
| 28 | +as `Unknown` instead of causing the program to fail. | ||
| 29 | + | ||
| 30 | +## Test | ||
| 31 | + | ||
| 32 | +```sh | ||
| 33 | +nimble test | ||
| 34 | +``` | ||
added
nimfetch.nimble +14 -0 | new file mode 100644 | ||
| @@ -0,0 +1,14 @@ | ||
| 1 | +version = "0.1.0" | |
| 2 | +author = "nimfetch contributors" | |
| 3 | +description = "A fast, dependency-free system information tool written in Nim" | |
| 4 | +license = "MIT" | |
| 5 | +srcDir = "src" | |
| 6 | +bin = @["nimfetch"] | |
| 7 | + | |
| 8 | +requires "nim >= 2.0.0" | |
| 9 | + | |
| 10 | +task test, "Run the test suite": | |
| 11 | + exec "nim c -r --nimcache:/tmp/nimfetch-tests --path:src tests/test_nimfetch.nim" | |
| 12 | + | |
| 13 | +task release, "Build an optimized binary": | |
| 14 | + exec "nim c -d:release --opt:speed --nimcache:/tmp/nimfetch-release -o:bin/nimfetch src/nimfetch.nim" | |
| new file mode 100644 | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | +version = "0.1.0" | ||
| 2 | +author = "nimfetch contributors" | ||
| 3 | +description = "A fast, dependency-free system information tool written in Nim" | ||
| 4 | +license = "MIT" | ||
| 5 | +srcDir = "src" | ||
| 6 | +bin = @["nimfetch"] | ||
| 7 | + | ||
| 8 | +requires "nim >= 2.0.0" | ||
| 9 | + | ||
| 10 | +task test, "Run the test suite": | ||
| 11 | + exec "nim c -r --nimcache:/tmp/nimfetch-tests --path:src tests/test_nimfetch.nim" | ||
| 12 | + | ||
| 13 | +task release, "Build an optimized binary": | ||
| 14 | + exec "nim c -d:release --opt:speed --nimcache:/tmp/nimfetch-release -o:bin/nimfetch src/nimfetch.nim" | ||
added
src/nimfetch.nim +105 -0 | new file mode 100644 | ||
| @@ -0,0 +1,105 @@ | ||
| 1 | +import std/[json, os, sequtils, strutils, terminal] | |
| 2 | +import sysinfo | |
| 3 | + | |
| 4 | +const Version = "0.1.0" | |
| 5 | + | |
| 6 | +type Options = object | |
| 7 | + logo, color, json: bool | |
| 8 | + | |
| 9 | +type DisplayRow = object | |
| 10 | + key, value: string | |
| 11 | + isTitle, isSeparator, isPalette: bool | |
| 12 | + | |
| 13 | +proc logoFor(name: string): seq[string] = | |
| 14 | + case name | |
| 15 | + of "arch": @[r" /\", r" / \", r" /\ \", r" / \", | |
| 16 | + r" / ,, \", r" / | | -\", r" /_-'' ''-_\"] | |
| 17 | + of "ubuntu": @[" _", " ---(@)", r" _/ --- \", "(@) | |", | |
| 18 | + r" \ --- _/", " ---(@)"] | |
| 19 | + of "debian": @[" _____", r" / __ \", "| / |", r"| \___-", "-_", " --_"] | |
| 20 | + of "fedora": @[" _____", r" / __)\", r" | / \", " ___| |__/", | |
| 21 | + " / (_ _)", "/ / | |", r"\ \__/ |", r" \_____/"] | |
| 22 | + else: @[" .--.", " |o_o |", " |:_/ |", r" // \ \", " (| | )", | |
| 23 | + r"/'\_ _/'\", r"\___)=(___/"] | |
| 24 | + | |
| 25 | +proc help() = | |
| 26 | + echo "nimfetch ", Version, " - fast system information in Nim" | |
| 27 | + echo "" | |
| 28 | + echo "Usage: nimfetch [options]" | |
| 29 | + echo " --no-logo Hide the ASCII logo" | |
| 30 | + echo " --logo <name> Use arch, ubuntu, debian, fedora, or linux" | |
| 31 | + echo " --no-color Disable ANSI colors" | |
| 32 | + echo " --json Print machine-readable JSON" | |
| 33 | + echo " -v, --version Show version" | |
| 34 | + echo " -h, --help Show this help" | |
| 35 | + | |
| 36 | +proc parseOptions(): tuple[options: Options, logoName: string] = | |
| 37 | + result.options = Options(logo: true, color: true) | |
| 38 | + var i = 1 | |
| 39 | + while i <= paramCount(): | |
| 40 | + case paramStr(i) | |
| 41 | + of "--no-logo": result.options.logo = false | |
| 42 | + of "--no-color": result.options.color = false | |
| 43 | + of "--json": result.options.json = true; result.options.logo = false | |
| 44 | + of "--logo": | |
| 45 | + inc i | |
| 46 | + if i > paramCount(): | |
| 47 | + stderr.writeLine "nimfetch: --logo requires a name" | |
| 48 | + quit 2 | |
| 49 | + result.logoName = paramStr(i).toLowerAscii | |
| 50 | + of "-v", "--version": echo Version; quit 0 | |
| 51 | + of "-h", "--help": help(); quit 0 | |
| 52 | + else: | |
| 53 | + stderr.writeLine "nimfetch: unknown option: " & paramStr(i) | |
| 54 | + stderr.writeLine "Try 'nimfetch --help'." | |
| 55 | + quit 2 | |
| 56 | + inc i | |
| 57 | + | |
| 58 | +proc colored(text, code: string, enabled: bool): string = | |
| 59 | + if enabled: "\e[" & code & "m" & text & "\e[0m" else: text | |
| 60 | + | |
| 61 | +proc main() = | |
| 62 | + var (options, logoName) = parseOptions() | |
| 63 | + if not isatty(stdout): options.color = false | |
| 64 | + let info = collectInfo() | |
| 65 | + | |
| 66 | + if options.json: | |
| 67 | + var output = newJObject() | |
| 68 | + for item in info: | |
| 69 | + output[item.key.toLowerAscii.replace(" ", "_").replace("(/)", "root")] = %item.value | |
| 70 | + echo pretty(output) | |
| 71 | + return | |
| 72 | + | |
| 73 | + if logoName.len == 0: logoName = osRelease().id | |
| 74 | + if logoName notin ["arch", "ubuntu", "debian", "fedora", "linux"]: logoName = "linux" | |
| 75 | + let logo = if options.logo: logoFor(logoName) else: @[] | |
| 76 | + var displayRows: seq[DisplayRow] | |
| 77 | + for item in info: | |
| 78 | + if item.key == "Title": | |
| 79 | + displayRows.add DisplayRow(value: item.value, isTitle: true) | |
| 80 | + displayRows.add DisplayRow(value: "-".repeat(item.value.len), isSeparator: true) | |
| 81 | + else: | |
| 82 | + displayRows.add DisplayRow(key: item.key, value: item.value) | |
| 83 | + displayRows.add DisplayRow(value: "● ● ● ● ● ● ● ●", isPalette: true) | |
| 84 | + | |
| 85 | + let rows = max(logo.len, displayRows.len) | |
| 86 | + let width = if logo.len > 0: logo.mapIt(it.len).max + 4 else: 0 | |
| 87 | + | |
| 88 | + for i in 0 ..< rows: | |
| 89 | + let left = if i < logo.len: colored(logo[i].alignLeft(width), "36;1", options.color) | |
| 90 | + else: " ".repeat(width) | |
| 91 | + if i < displayRows.len: | |
| 92 | + let row = displayRows[i] | |
| 93 | + if row.isTitle: | |
| 94 | + echo left, colored(row.value, "32;1", options.color) | |
| 95 | + elif row.isSeparator: | |
| 96 | + echo left, colored(row.value, "32", options.color) | |
| 97 | + elif row.isPalette: | |
| 98 | + echo left, colored(row.value, "36;1", options.color) | |
| 99 | + else: | |
| 100 | + echo left, colored(row.key & ":", "34;1", options.color), " ", row.value | |
| 101 | + else: | |
| 102 | + echo left | |
| 103 | + | |
| 104 | +when isMainModule: | |
| 105 | + main() | |
| new file mode 100644 | |||
| @@ -0,0 +1,105 @@ | |||
| 1 | +import std/[json, os, sequtils, strutils, terminal] | ||
| 2 | +import sysinfo | ||
| 3 | + | ||
| 4 | +const Version = "0.1.0" | ||
| 5 | + | ||
| 6 | +type Options = object | ||
| 7 | + logo, color, json: bool | ||
| 8 | + | ||
| 9 | +type DisplayRow = object | ||
| 10 | + key, value: string | ||
| 11 | + isTitle, isSeparator, isPalette: bool | ||
| 12 | + | ||
| 13 | +proc logoFor(name: string): seq[string] = | ||
| 14 | + case name | ||
| 15 | + of "arch": @[r" /\", r" / \", r" /\ \", r" / \", | ||
| 16 | + r" / ,, \", r" / | | -\", r" /_-'' ''-_\"] | ||
| 17 | + of "ubuntu": @[" _", " ---(@)", r" _/ --- \", "(@) | |", | ||
| 18 | + r" \ --- _/", " ---(@)"] | ||
| 19 | + of "debian": @[" _____", r" / __ \", "| / |", r"| \___-", "-_", " --_"] | ||
| 20 | + of "fedora": @[" _____", r" / __)\", r" | / \", " ___| |__/", | ||
| 21 | + " / (_ _)", "/ / | |", r"\ \__/ |", r" \_____/"] | ||
| 22 | + else: @[" .--.", " |o_o |", " |:_/ |", r" // \ \", " (| | )", | ||
| 23 | + r"/'\_ _/'\", r"\___)=(___/"] | ||
| 24 | + | ||
| 25 | +proc help() = | ||
| 26 | + echo "nimfetch ", Version, " - fast system information in Nim" | ||
| 27 | + echo "" | ||
| 28 | + echo "Usage: nimfetch [options]" | ||
| 29 | + echo " --no-logo Hide the ASCII logo" | ||
| 30 | + echo " --logo <name> Use arch, ubuntu, debian, fedora, or linux" | ||
| 31 | + echo " --no-color Disable ANSI colors" | ||
| 32 | + echo " --json Print machine-readable JSON" | ||
| 33 | + echo " -v, --version Show version" | ||
| 34 | + echo " -h, --help Show this help" | ||
| 35 | + | ||
| 36 | +proc parseOptions(): tuple[options: Options, logoName: string] = | ||
| 37 | + result.options = Options(logo: true, color: true) | ||
| 38 | + var i = 1 | ||
| 39 | + while i <= paramCount(): | ||
| 40 | + case paramStr(i) | ||
| 41 | + of "--no-logo": result.options.logo = false | ||
| 42 | + of "--no-color": result.options.color = false | ||
| 43 | + of "--json": result.options.json = true; result.options.logo = false | ||
| 44 | + of "--logo": | ||
| 45 | + inc i | ||
| 46 | + if i > paramCount(): | ||
| 47 | + stderr.writeLine "nimfetch: --logo requires a name" | ||
| 48 | + quit 2 | ||
| 49 | + result.logoName = paramStr(i).toLowerAscii | ||
| 50 | + of "-v", "--version": echo Version; quit 0 | ||
| 51 | + of "-h", "--help": help(); quit 0 | ||
| 52 | + else: | ||
| 53 | + stderr.writeLine "nimfetch: unknown option: " & paramStr(i) | ||
| 54 | + stderr.writeLine "Try 'nimfetch --help'." | ||
| 55 | + quit 2 | ||
| 56 | + inc i | ||
| 57 | + | ||
| 58 | +proc colored(text, code: string, enabled: bool): string = | ||
| 59 | + if enabled: "\e[" & code & "m" & text & "\e[0m" else: text | ||
| 60 | + | ||
| 61 | +proc main() = | ||
| 62 | + var (options, logoName) = parseOptions() | ||
| 63 | + if not isatty(stdout): options.color = false | ||
| 64 | + let info = collectInfo() | ||
| 65 | + | ||
| 66 | + if options.json: | ||
| 67 | + var output = newJObject() | ||
| 68 | + for item in info: | ||
| 69 | + output[item.key.toLowerAscii.replace(" ", "_").replace("(/)", "root")] = %item.value | ||
| 70 | + echo pretty(output) | ||
| 71 | + return | ||
| 72 | + | ||
| 73 | + if logoName.len == 0: logoName = osRelease().id | ||
| 74 | + if logoName notin ["arch", "ubuntu", "debian", "fedora", "linux"]: logoName = "linux" | ||
| 75 | + let logo = if options.logo: logoFor(logoName) else: @[] | ||
| 76 | + var displayRows: seq[DisplayRow] | ||
| 77 | + for item in info: | ||
| 78 | + if item.key == "Title": | ||
| 79 | + displayRows.add DisplayRow(value: item.value, isTitle: true) | ||
| 80 | + displayRows.add DisplayRow(value: "-".repeat(item.value.len), isSeparator: true) | ||
| 81 | + else: | ||
| 82 | + displayRows.add DisplayRow(key: item.key, value: item.value) | ||
| 83 | + displayRows.add DisplayRow(value: "● ● ● ● ● ● ● ●", isPalette: true) | ||
| 84 | + | ||
| 85 | + let rows = max(logo.len, displayRows.len) | ||
| 86 | + let width = if logo.len > 0: logo.mapIt(it.len).max + 4 else: 0 | ||
| 87 | + | ||
| 88 | + for i in 0 ..< rows: | ||
| 89 | + let left = if i < logo.len: colored(logo[i].alignLeft(width), "36;1", options.color) | ||
| 90 | + else: " ".repeat(width) | ||
| 91 | + if i < displayRows.len: | ||
| 92 | + let row = displayRows[i] | ||
| 93 | + if row.isTitle: | ||
| 94 | + echo left, colored(row.value, "32;1", options.color) | ||
| 95 | + elif row.isSeparator: | ||
| 96 | + echo left, colored(row.value, "32", options.color) | ||
| 97 | + elif row.isPalette: | ||
| 98 | + echo left, colored(row.value, "36;1", options.color) | ||
| 99 | + else: | ||
| 100 | + echo left, colored(row.key & ":", "34;1", options.color), " ", row.value | ||
| 101 | + else: | ||
| 102 | + echo left | ||
| 103 | + | ||
| 104 | +when isMainModule: | ||
| 105 | + main() | ||
added
src/sysinfo.nim +173 -0 | new file mode 100644 | ||
| @@ -0,0 +1,173 @@ | ||
| 1 | +import std/[os, osproc, strformat, strutils] | |
| 2 | + | |
| 3 | +type | |
| 4 | + InfoItem* = object | |
| 5 | + key*, value*: string | |
| 6 | + | |
| 7 | +proc readFirst(path: string): string = | |
| 8 | + try: | |
| 9 | + readFile(path).strip | |
| 10 | + except CatchableError: | |
| 11 | + "" | |
| 12 | + | |
| 13 | +proc command(cmd: string): string = | |
| 14 | + try: | |
| 15 | + let execution = execCmdEx(cmd, options = {poUsePath, poStdErrToStdOut}) | |
| 16 | + if execution.exitCode == 0: execution.output.strip else: "" | |
| 17 | + except CatchableError: | |
| 18 | + "" | |
| 19 | + | |
| 20 | +proc osRelease*(): tuple[name, id: string] = | |
| 21 | + result = (hostOS.capitalizeAscii, hostOS) | |
| 22 | + when defined(linux): | |
| 23 | + for line in readFirst("/etc/os-release").splitLines: | |
| 24 | + let pos = line.find('=') | |
| 25 | + if pos < 1: continue | |
| 26 | + let key = line[0 ..< pos] | |
| 27 | + let value = line[pos + 1 .. ^1].strip(chars = {'"', '\''}) | |
| 28 | + case key | |
| 29 | + of "PRETTY_NAME": result.name = value | |
| 30 | + of "ID": result.id = value.toLowerAscii | |
| 31 | + else: discard | |
| 32 | + | |
| 33 | +proc formatBytes*(bytes: int64): string = | |
| 34 | + const units = ["B", "KiB", "MiB", "GiB", "TiB"] | |
| 35 | + var value = bytes.float | |
| 36 | + var unit = 0 | |
| 37 | + while value >= 1024 and unit < units.high: | |
| 38 | + value /= 1024 | |
| 39 | + inc unit | |
| 40 | + if unit == 0: &"{value.int} {units[unit]}" else: &"{value:.2f} {units[unit]}" | |
| 41 | + | |
| 42 | +proc formatDuration*(seconds: int64): string = | |
| 43 | + let days = seconds div 86400 | |
| 44 | + let hours = (seconds mod 86400) div 3600 | |
| 45 | + let minutes = (seconds mod 3600) div 60 | |
| 46 | + var parts: seq[string] | |
| 47 | + if days > 0: parts.add &"{days}d" | |
| 48 | + if hours > 0: parts.add &"{hours}h" | |
| 49 | + parts.add &"{minutes}m" | |
| 50 | + parts.join(" ") | |
| 51 | + | |
| 52 | +proc kernel(): string = | |
| 53 | + when defined(windows): | |
| 54 | + result = command("ver") | |
| 55 | + else: | |
| 56 | + result = command("uname -sr") | |
| 57 | + | |
| 58 | +proc uptime(): string = | |
| 59 | + when defined(linux): | |
| 60 | + let raw = readFirst("/proc/uptime").splitWhitespace | |
| 61 | + if raw.len > 0: | |
| 62 | + try: return formatDuration(int64(parseFloat(raw[0]))) | |
| 63 | + except ValueError: discard | |
| 64 | + elif defined(macosx): | |
| 65 | + import std/[parseutils, times] | |
| 66 | + let boot = command("sysctl -n kern.boottime") | |
| 67 | + let marker = "sec = " | |
| 68 | + let start = boot.find(marker) | |
| 69 | + if start >= 0: | |
| 70 | + var epoch: int | |
| 71 | + discard parseInt(boot, epoch, start + marker.len) | |
| 72 | + return formatDuration(getTime().toUnix - epoch) | |
| 73 | + "Unknown" | |
| 74 | + | |
| 75 | +proc cpu(): string = | |
| 76 | + when defined(linux): | |
| 77 | + for line in readFirst("/proc/cpuinfo").splitLines: | |
| 78 | + if line.startsWith("model name") or line.startsWith("Hardware"): | |
| 79 | + let pos = line.find(':') | |
| 80 | + if pos >= 0: return line[pos + 1 .. ^1].strip | |
| 81 | + elif defined(macosx): | |
| 82 | + result = command("sysctl -n machdep.cpu.brand_string") | |
| 83 | + elif defined(windows): | |
| 84 | + result = getEnv("PROCESSOR_IDENTIFIER") | |
| 85 | + if result.len == 0: result = "Unknown" | |
| 86 | + | |
| 87 | +proc gpu(): string = | |
| 88 | + when defined(linux): | |
| 89 | + result = command("lspci 2>/dev/null | sed -n '/VGA\\|3D controller/{s/.*: //;p;q;}'") | |
| 90 | + elif defined(macosx): | |
| 91 | + result = command("system_profiler SPDisplaysDataType | awk -F': ' '/Chipset Model/{print $2; exit}'") | |
| 92 | + if result.len == 0: result = "Unknown" | |
| 93 | + | |
| 94 | +proc memory(): string = | |
| 95 | + when defined(linux): | |
| 96 | + var total, available: int64 | |
| 97 | + for line in readFirst("/proc/meminfo").splitLines: | |
| 98 | + let fields = line.splitWhitespace | |
| 99 | + if fields.len < 2: continue | |
| 100 | + try: | |
| 101 | + if fields[0] == "MemTotal:": total = parseInt(fields[1]).int64 * 1024 | |
| 102 | + elif fields[0] == "MemAvailable:": available = parseInt(fields[1]).int64 * 1024 | |
| 103 | + except ValueError: discard | |
| 104 | + if total > 0: return &"{formatBytes(total - available)} / {formatBytes(total)}" | |
| 105 | + elif defined(macosx): | |
| 106 | + let total = command("sysctl -n hw.memsize") | |
| 107 | + if total.len > 0: return formatBytes(parseBiggestInt(total)) | |
| 108 | + "Unknown" | |
| 109 | + | |
| 110 | +proc disk(): string = | |
| 111 | + when defined(posix): | |
| 112 | + let line = command("df -Pk / | tail -1").splitWhitespace | |
| 113 | + if line.len >= 5: | |
| 114 | + try: | |
| 115 | + let total = parseBiggestInt(line[1]) * 1024 | |
| 116 | + let used = parseBiggestInt(line[2]) * 1024 | |
| 117 | + return &"{formatBytes(used)} / {formatBytes(total)} ({line[4]})" | |
| 118 | + except ValueError: discard | |
| 119 | + "Unknown" | |
| 120 | + | |
| 121 | +proc packageCount(): string = | |
| 122 | + var counts: seq[string] | |
| 123 | + when defined(linux): | |
| 124 | + let managers = [ | |
| 125 | + ("dpkg", "dpkg-query -f '${binary:Package}\\n' -W 2>/dev/null | wc -l"), | |
| 126 | + ("rpm", "rpm -qa 2>/dev/null | wc -l"), | |
| 127 | + ("pacman", "pacman -Qq 2>/dev/null | wc -l"), | |
| 128 | + ("apk", "apk info 2>/dev/null | wc -l"), | |
| 129 | + ("flatpak", "flatpak list --app --columns=application 2>/dev/null | wc -l")] | |
| 130 | + for (manager, cmd) in managers.items: | |
| 131 | + if findExe(manager).len > 0: | |
| 132 | + let count = command(cmd) | |
| 133 | + if count.len > 0 and count != "0": counts.add &"{count} ({manager})" | |
| 134 | + if counts.len == 0: "Unknown" else: counts.join(", ") | |
| 135 | + | |
| 136 | +proc desktop(): string = | |
| 137 | + result = getEnv("XDG_CURRENT_DESKTOP") | |
| 138 | + if result.len == 0: result = getEnv("DESKTOP_SESSION") | |
| 139 | + if result.len == 0: result = "TTY" | |
| 140 | + | |
| 141 | +proc localIp(): string = | |
| 142 | + when defined(linux): | |
| 143 | + let route = command("ip route get 1.1.1.1 2>/dev/null") | |
| 144 | + let words = route.splitWhitespace | |
| 145 | + let pos = words.find("src") | |
| 146 | + if pos >= 0 and pos + 1 < words.len: return words[pos + 1] | |
| 147 | + let addresses = command("hostname -I 2>/dev/null").splitWhitespace | |
| 148 | + if addresses.len > 0: return addresses[0] | |
| 149 | + result = "Unknown" | |
| 150 | + | |
| 151 | +proc collectInfo*(): seq[InfoItem] = | |
| 152 | + let distro = osRelease() | |
| 153 | + let user = getEnv("USER", getEnv("USERNAME", "user")) | |
| 154 | + let host = getEnv("HOSTNAME", command("hostname")) | |
| 155 | + let shellPath = getEnv("SHELL", getEnv("COMSPEC", "Unknown")) | |
| 156 | + let terminal = getEnv("TERM_PROGRAM", getEnv("TERM", "Unknown")) | |
| 157 | + result = @[ | |
| 158 | + InfoItem(key: "Title", value: user & "@" & host), | |
| 159 | + InfoItem(key: "OS", value: distro.name), | |
| 160 | + InfoItem(key: "Host", value: host), | |
| 161 | + InfoItem(key: "Kernel", value: kernel()), | |
| 162 | + InfoItem(key: "Uptime", value: uptime()), | |
| 163 | + InfoItem(key: "Packages", value: packageCount()), | |
| 164 | + InfoItem(key: "Shell", value: extractFilename(shellPath)), | |
| 165 | + InfoItem(key: "Display", value: getEnv("WAYLAND_DISPLAY", getEnv("DISPLAY", "TTY"))), | |
| 166 | + InfoItem(key: "DE/WM", value: desktop()), | |
| 167 | + InfoItem(key: "Terminal", value: terminal), | |
| 168 | + InfoItem(key: "CPU", value: cpu()), | |
| 169 | + InfoItem(key: "GPU", value: gpu()), | |
| 170 | + InfoItem(key: "Memory", value: memory()), | |
| 171 | + InfoItem(key: "Disk (/)" , value: disk()), | |
| 172 | + InfoItem(key: "Local IP", value: localIp()), | |
| 173 | + InfoItem(key: "Locale", value: getEnv("LANG", "Unknown"))] | |
| new file mode 100644 | |||
| @@ -0,0 +1,173 @@ | |||
| 1 | +import std/[os, osproc, strformat, strutils] | ||
| 2 | + | ||
| 3 | +type | ||
| 4 | + InfoItem* = object | ||
| 5 | + key*, value*: string | ||
| 6 | + | ||
| 7 | +proc readFirst(path: string): string = | ||
| 8 | + try: | ||
| 9 | + readFile(path).strip | ||
| 10 | + except CatchableError: | ||
| 11 | + "" | ||
| 12 | + | ||
| 13 | +proc command(cmd: string): string = | ||
| 14 | + try: | ||
| 15 | + let execution = execCmdEx(cmd, options = {poUsePath, poStdErrToStdOut}) | ||
| 16 | + if execution.exitCode == 0: execution.output.strip else: "" | ||
| 17 | + except CatchableError: | ||
| 18 | + "" | ||
| 19 | + | ||
| 20 | +proc osRelease*(): tuple[name, id: string] = | ||
| 21 | + result = (hostOS.capitalizeAscii, hostOS) | ||
| 22 | + when defined(linux): | ||
| 23 | + for line in readFirst("/etc/os-release").splitLines: | ||
| 24 | + let pos = line.find('=') | ||
| 25 | + if pos < 1: continue | ||
| 26 | + let key = line[0 ..< pos] | ||
| 27 | + let value = line[pos + 1 .. ^1].strip(chars = {'"', '\''}) | ||
| 28 | + case key | ||
| 29 | + of "PRETTY_NAME": result.name = value | ||
| 30 | + of "ID": result.id = value.toLowerAscii | ||
| 31 | + else: discard | ||
| 32 | + | ||
| 33 | +proc formatBytes*(bytes: int64): string = | ||
| 34 | + const units = ["B", "KiB", "MiB", "GiB", "TiB"] | ||
| 35 | + var value = bytes.float | ||
| 36 | + var unit = 0 | ||
| 37 | + while value >= 1024 and unit < units.high: | ||
| 38 | + value /= 1024 | ||
| 39 | + inc unit | ||
| 40 | + if unit == 0: &"{value.int} {units[unit]}" else: &"{value:.2f} {units[unit]}" | ||
| 41 | + | ||
| 42 | +proc formatDuration*(seconds: int64): string = | ||
| 43 | + let days = seconds div 86400 | ||
| 44 | + let hours = (seconds mod 86400) div 3600 | ||
| 45 | + let minutes = (seconds mod 3600) div 60 | ||
| 46 | + var parts: seq[string] | ||
| 47 | + if days > 0: parts.add &"{days}d" | ||
| 48 | + if hours > 0: parts.add &"{hours}h" | ||
| 49 | + parts.add &"{minutes}m" | ||
| 50 | + parts.join(" ") | ||
| 51 | + | ||
| 52 | +proc kernel(): string = | ||
| 53 | + when defined(windows): | ||
| 54 | + result = command("ver") | ||
| 55 | + else: | ||
| 56 | + result = command("uname -sr") | ||
| 57 | + | ||
| 58 | +proc uptime(): string = | ||
| 59 | + when defined(linux): | ||
| 60 | + let raw = readFirst("/proc/uptime").splitWhitespace | ||
| 61 | + if raw.len > 0: | ||
| 62 | + try: return formatDuration(int64(parseFloat(raw[0]))) | ||
| 63 | + except ValueError: discard | ||
| 64 | + elif defined(macosx): | ||
| 65 | + import std/[parseutils, times] | ||
| 66 | + let boot = command("sysctl -n kern.boottime") | ||
| 67 | + let marker = "sec = " | ||
| 68 | + let start = boot.find(marker) | ||
| 69 | + if start >= 0: | ||
| 70 | + var epoch: int | ||
| 71 | + discard parseInt(boot, epoch, start + marker.len) | ||
| 72 | + return formatDuration(getTime().toUnix - epoch) | ||
| 73 | + "Unknown" | ||
| 74 | + | ||
| 75 | +proc cpu(): string = | ||
| 76 | + when defined(linux): | ||
| 77 | + for line in readFirst("/proc/cpuinfo").splitLines: | ||
| 78 | + if line.startsWith("model name") or line.startsWith("Hardware"): | ||
| 79 | + let pos = line.find(':') | ||
| 80 | + if pos >= 0: return line[pos + 1 .. ^1].strip | ||
| 81 | + elif defined(macosx): | ||
| 82 | + result = command("sysctl -n machdep.cpu.brand_string") | ||
| 83 | + elif defined(windows): | ||
| 84 | + result = getEnv("PROCESSOR_IDENTIFIER") | ||
| 85 | + if result.len == 0: result = "Unknown" | ||
| 86 | + | ||
| 87 | +proc gpu(): string = | ||
| 88 | + when defined(linux): | ||
| 89 | + result = command("lspci 2>/dev/null | sed -n '/VGA\\|3D controller/{s/.*: //;p;q;}'") | ||
| 90 | + elif defined(macosx): | ||
| 91 | + result = command("system_profiler SPDisplaysDataType | awk -F': ' '/Chipset Model/{print $2; exit}'") | ||
| 92 | + if result.len == 0: result = "Unknown" | ||
| 93 | + | ||
| 94 | +proc memory(): string = | ||
| 95 | + when defined(linux): | ||
| 96 | + var total, available: int64 | ||
| 97 | + for line in readFirst("/proc/meminfo").splitLines: | ||
| 98 | + let fields = line.splitWhitespace | ||
| 99 | + if fields.len < 2: continue | ||
| 100 | + try: | ||
| 101 | + if fields[0] == "MemTotal:": total = parseInt(fields[1]).int64 * 1024 | ||
| 102 | + elif fields[0] == "MemAvailable:": available = parseInt(fields[1]).int64 * 1024 | ||
| 103 | + except ValueError: discard | ||
| 104 | + if total > 0: return &"{formatBytes(total - available)} / {formatBytes(total)}" | ||
| 105 | + elif defined(macosx): | ||
| 106 | + let total = command("sysctl -n hw.memsize") | ||
| 107 | + if total.len > 0: return formatBytes(parseBiggestInt(total)) | ||
| 108 | + "Unknown" | ||
| 109 | + | ||
| 110 | +proc disk(): string = | ||
| 111 | + when defined(posix): | ||
| 112 | + let line = command("df -Pk / | tail -1").splitWhitespace | ||
| 113 | + if line.len >= 5: | ||
| 114 | + try: | ||
| 115 | + let total = parseBiggestInt(line[1]) * 1024 | ||
| 116 | + let used = parseBiggestInt(line[2]) * 1024 | ||
| 117 | + return &"{formatBytes(used)} / {formatBytes(total)} ({line[4]})" | ||
| 118 | + except ValueError: discard | ||
| 119 | + "Unknown" | ||
| 120 | + | ||
| 121 | +proc packageCount(): string = | ||
| 122 | + var counts: seq[string] | ||
| 123 | + when defined(linux): | ||
| 124 | + let managers = [ | ||
| 125 | + ("dpkg", "dpkg-query -f '${binary:Package}\\n' -W 2>/dev/null | wc -l"), | ||
| 126 | + ("rpm", "rpm -qa 2>/dev/null | wc -l"), | ||
| 127 | + ("pacman", "pacman -Qq 2>/dev/null | wc -l"), | ||
| 128 | + ("apk", "apk info 2>/dev/null | wc -l"), | ||
| 129 | + ("flatpak", "flatpak list --app --columns=application 2>/dev/null | wc -l")] | ||
| 130 | + for (manager, cmd) in managers.items: | ||
| 131 | + if findExe(manager).len > 0: | ||
| 132 | + let count = command(cmd) | ||
| 133 | + if count.len > 0 and count != "0": counts.add &"{count} ({manager})" | ||
| 134 | + if counts.len == 0: "Unknown" else: counts.join(", ") | ||
| 135 | + | ||
| 136 | +proc desktop(): string = | ||
| 137 | + result = getEnv("XDG_CURRENT_DESKTOP") | ||
| 138 | + if result.len == 0: result = getEnv("DESKTOP_SESSION") | ||
| 139 | + if result.len == 0: result = "TTY" | ||
| 140 | + | ||
| 141 | +proc localIp(): string = | ||
| 142 | + when defined(linux): | ||
| 143 | + let route = command("ip route get 1.1.1.1 2>/dev/null") | ||
| 144 | + let words = route.splitWhitespace | ||
| 145 | + let pos = words.find("src") | ||
| 146 | + if pos >= 0 and pos + 1 < words.len: return words[pos + 1] | ||
| 147 | + let addresses = command("hostname -I 2>/dev/null").splitWhitespace | ||
| 148 | + if addresses.len > 0: return addresses[0] | ||
| 149 | + result = "Unknown" | ||
| 150 | + | ||
| 151 | +proc collectInfo*(): seq[InfoItem] = | ||
| 152 | + let distro = osRelease() | ||
| 153 | + let user = getEnv("USER", getEnv("USERNAME", "user")) | ||
| 154 | + let host = getEnv("HOSTNAME", command("hostname")) | ||
| 155 | + let shellPath = getEnv("SHELL", getEnv("COMSPEC", "Unknown")) | ||
| 156 | + let terminal = getEnv("TERM_PROGRAM", getEnv("TERM", "Unknown")) | ||
| 157 | + result = @[ | ||
| 158 | + InfoItem(key: "Title", value: user & "@" & host), | ||
| 159 | + InfoItem(key: "OS", value: distro.name), | ||
| 160 | + InfoItem(key: "Host", value: host), | ||
| 161 | + InfoItem(key: "Kernel", value: kernel()), | ||
| 162 | + InfoItem(key: "Uptime", value: uptime()), | ||
| 163 | + InfoItem(key: "Packages", value: packageCount()), | ||
| 164 | + InfoItem(key: "Shell", value: extractFilename(shellPath)), | ||
| 165 | + InfoItem(key: "Display", value: getEnv("WAYLAND_DISPLAY", getEnv("DISPLAY", "TTY"))), | ||
| 166 | + InfoItem(key: "DE/WM", value: desktop()), | ||
| 167 | + InfoItem(key: "Terminal", value: terminal), | ||
| 168 | + InfoItem(key: "CPU", value: cpu()), | ||
| 169 | + InfoItem(key: "GPU", value: gpu()), | ||
| 170 | + InfoItem(key: "Memory", value: memory()), | ||
| 171 | + InfoItem(key: "Disk (/)" , value: disk()), | ||
| 172 | + InfoItem(key: "Local IP", value: localIp()), | ||
| 173 | + InfoItem(key: "Locale", value: getEnv("LANG", "Unknown"))] | ||
added
tests/test_nimfetch.nim +19 -0 | new file mode 100644 | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +import std/[unittest] | |
| 2 | +import sysinfo | |
| 3 | + | |
| 4 | +suite "nimfetch helpers": | |
| 5 | + test "formats byte quantities": | |
| 6 | + check formatBytes(0) == "0 B" | |
| 7 | + check formatBytes(1024) == "1.00 KiB" | |
| 8 | + check formatBytes(1_073_741_824) == "1.00 GiB" | |
| 9 | + | |
| 10 | + test "formats durations": | |
| 11 | + check formatDuration(60) == "1m" | |
| 12 | + check formatDuration(3660) == "1h 1m" | |
| 13 | + check formatDuration(90060) == "1d 1h 1m" | |
| 14 | + | |
| 15 | + test "collects core fields": | |
| 16 | + let info = collectInfo() | |
| 17 | + check info.len >= 10 | |
| 18 | + check info[0].key == "Title" | |
| 19 | + check info[1].key == "OS" | |
| new file mode 100644 | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | +import std/[unittest] | ||
| 2 | +import sysinfo | ||
| 3 | + | ||
| 4 | +suite "nimfetch helpers": | ||
| 5 | + test "formats byte quantities": | ||
| 6 | + check formatBytes(0) == "0 B" | ||
| 7 | + check formatBytes(1024) == "1.00 KiB" | ||
| 8 | + check formatBytes(1_073_741_824) == "1.00 GiB" | ||
| 9 | + | ||
| 10 | + test "formats durations": | ||
| 11 | + check formatDuration(60) == "1m" | ||
| 12 | + check formatDuration(3660) == "1h 1m" | ||
| 13 | + check formatDuration(90060) == "1d 1h 1m" | ||
| 14 | + | ||
| 15 | + test "collects core fields": | ||
| 16 | + let info = collectInfo() | ||
| 17 | + check info.len >= 10 | ||
| 18 | + check info[0].key == "Title" | ||
| 19 | + check info[1].key == "OS" | ||