aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-17 12:10:22 +0200
committerDanilo M. <danix@danix.xyz>2026-09-17 12:10:22 +0200
commit518b2969108ed3d728e562614c05e40400129240 (patch)
tree502a61a38db9d9ee35eb0e3f770e28596847ace3
parent3690d0ee9ceb5c0287cfe72eb4dcfd8f43324f0e (diff)
downloadconky-theme-udt-518b2969108ed3d728e562614c05e40400129240.tar.gz
conky-theme-udt-518b2969108ed3d728e562614c05e40400129240.zip
feat: parse df and du output for the disk and cache cards
-rw-r--r--lib/data.lua76
-rw-r--r--test/fixtures/df_output7
-rw-r--r--test/fixtures/du_output5
-rw-r--r--test/test_data.lua59
4 files changed, 147 insertions, 0 deletions
diff --git a/lib/data.lua b/lib/data.lua
index 9bd5433..acf7a0f 100644
--- a/lib/data.lua
+++ b/lib/data.lua
@@ -152,4 +152,80 @@ function M.sensor_raw(chip, file)
return tonumber(s:match('^%s*(-?%d+)'))
end
+-- Parse `df -P -B1` output into a list of filesystems.
+--
+-- The mountpoint is taken as the LAST field, not the sixth. df -P guarantees
+-- one line per filesystem with the mountpoint last, and an NFS device reads
+-- 'server:/export', so counting fields from the left is fragile in a way
+-- counting from the right is not.
+--
+-- Sizes are bytes because the sampler passes -B1. Never parse `df -h` output
+-- here: the user's shell aliases df to df -h, so a sampler calling bare df
+-- would hand this function '1.6G' where it expects an integer.
+function M.df_parse(text)
+ local out = {}
+ if type(text) ~= 'string' then return out end
+ for line in text:gmatch('[^\n]+') do
+ -- A data row ends in 'NN% /some/path'. The header ends in 'Mounted on',
+ -- which fails the percent match, so it is skipped without a special case.
+ local size, used, pct, mount = line:match('(%d+)%s+(%d+)%s+%d+%s+(%d+)%%%s+(%S+)%s*$')
+ if size then
+ -- The device is kept so a caller can tell an NFS mount from a local one
+ -- and recover its server, which is how the disks card labels the two
+ -- shares without hardcoding an address in the repo.
+ local dev = line:match('^(%S+)')
+ out[#out + 1] = {
+ mount = mount,
+ dev = dev,
+ host = dev and dev:match('^([^/:]+):') or nil,
+ size = tonumber(size),
+ used = tonumber(used),
+ pct = tonumber(pct),
+ }
+ end
+ end
+ return out
+end
+
+-- Human-readable size to bytes: '1.1G' -> 1181116006.
+--
+-- Needed because du -sh is what the sampler runs (its output is also what the
+-- card displays), and 'biggest' has to be decided numerically: sorted as
+-- strings, '245M' beats '1.1G'.
+local SUFFIX = { K = 1024, M = 1024^2, G = 1024^3, T = 1024^4, P = 1024^5 }
+
+function M.human_bytes(s)
+ if type(s) ~= 'string' then return nil end
+ local n, suf = s:match('^%s*([%d%.]+)%s*([KMGTP]?)')
+ n = tonumber(n)
+ if not n then return nil end
+ return n * (SUFFIX[suf] or 1)
+end
+
+-- Parse `du -sh` output: the first line is the total, the rest are children
+-- already sorted largest-first by the sampler. Returns nil when there is
+-- nothing usable, so the widget can draw its "no data" state.
+function M.du_parse(text)
+ if type(text) ~= 'string' or text == '' then return nil end
+ local lines = {}
+ for line in text:gmatch('[^\n]+') do lines[#lines + 1] = line end
+ if #lines == 0 then return nil end
+
+ local function entry(line)
+ local label, path = line:match('^(%S+)%s+(.+)$')
+ if not label then return nil end
+ return { label = label, bytes = M.human_bytes(label) or 0,
+ name = path:match('([^/]+)/?$') or path, path = path }
+ end
+
+ local total = entry(lines[1])
+ if not total then return nil end
+ local items = {}
+ for i = 2, #lines do
+ local e = entry(lines[i])
+ if e then items[#items + 1] = e end
+ end
+ return { total = total, items = items }
+end
+
return M
diff --git a/test/fixtures/df_output b/test/fixtures/df_output
new file mode 100644
index 0000000..9205c83
--- /dev/null
+++ b/test/fixtures/df_output
@@ -0,0 +1,7 @@
+Filesystem 1-blocks Used Available Capacity Mounted on
+/dev/mapper/myvg-root 263174213632 208111570944 41746907136 84% /
+/dev/mapper/myvg-home 719407267840 219043332096 463804289024 33% /home
+/dev/sda1 983350091776 547869650944 385453473792 59% /data
+server-a:/Volume1/Library 3958241259520 2966853025792 815851831296 79% /mnt/nfs/Library
+server-b:/mnt/HD/HD_a2/share 982889670656 480039763968 503676862464 49% /mnt/nfs/shared
+/dev/full 100000000000 100000000000 0 100% /mnt/full
diff --git a/test/fixtures/du_output b/test/fixtures/du_output
new file mode 100644
index 0000000..a45b787
--- /dev/null
+++ b/test/fixtures/du_output
@@ -0,0 +1,5 @@
+1.6G /home/you/.cache
+1.1G /home/you/.cache/mozilla
+245M /home/you/.cache/pip
+131M /home/you/.cache/go-build
+85M /home/you/.cache/opencode
diff --git a/test/test_data.lua b/test/test_data.lua
index e5b589f..71baef0 100644
--- a/test/test_data.lua
+++ b/test/test_data.lua
@@ -91,4 +91,63 @@ assert(#data.per_cpu_times(nil) == 0, 'nil gives an empty list')
-- rather than producing a nonsense total.
assert(#data.per_cpu_times('cpu0 1 2\n') == 0, 'a short line is skipped')
+-- === Cache files ==========================================================
+-- df -P guarantees one line per mount with the mountpoint LAST, which is why
+-- the parser takes the last field rather than the sixth: an NFS device is
+-- 'server:/export' and a device name can carry surprises, but the mountpoint
+-- is always at the end.
+local fs = data.df_parse(read('test/fixtures/df_output'))
+assert(#fs == 6, 'one entry per mount, got ' .. tostring(#fs))
+assert(fs[1].mount == '/', 'first mount, got ' .. tostring(fs[1].mount))
+assert(fs[1].pct == 84, 'root percent, got ' .. tostring(fs[1].pct))
+assert(fs[1].size == 263174213632, 'root size in bytes, got ' .. tostring(fs[1].size))
+assert(fs[1].used == 208111570944, 'root used, got ' .. tostring(fs[1].used))
+
+-- The NFS rows are the reason the last field matters: a colon in the device
+-- would break a parser splitting on punctuation.
+assert(fs[4].mount == '/mnt/nfs/Library', 'nfs mount, got ' .. tostring(fs[4].mount))
+assert(fs[4].pct == 79, 'nfs percent, got ' .. tostring(fs[4].pct))
+
+-- The device field carries the server, which is what lets the disks card
+-- label the two shares without an address hardcoded in the repo.
+assert(fs[4].host == 'server-a', 'nfs host, got ' .. tostring(fs[4].host))
+assert(fs[1].host == nil, 'a local device has no host, got ' .. tostring(fs[1].host))
+
+-- A full filesystem is a real state, not an error.
+assert(fs[6].pct == 100, 'a full mount reads 100, got ' .. tostring(fs[6].pct))
+
+-- The header line must not become a row.
+for _, e in ipairs(fs) do
+ assert(e.mount ~= 'on' and e.mount ~= 'Mounted', 'the header leaked in as a row')
+end
+
+assert(#data.df_parse('') == 0, 'empty input gives an empty list')
+assert(#data.df_parse(nil) == 0, 'nil gives an empty list')
+-- A header with no rows is what a failed df produces.
+assert(#data.df_parse('Filesystem 1-blocks Used Available Capacity Mounted on\n') == 0,
+ 'a header alone gives an empty list')
+
+-- du -sh: total first, then children largest-first. The suffixes must be
+-- converted, not string-sorted: '1.1G' outranks '245M' numerically and loses
+-- to it alphabetically.
+local cache = data.du_parse(read('test/fixtures/du_output'))
+assert(cache.total, 'a total is parsed')
+assert(cache.total.label == '1.6G', 'total label, got ' .. tostring(cache.total.label))
+assert(#cache.items == 4, 'four children, got ' .. tostring(#cache.items))
+assert(cache.items[1].name == 'mozilla', 'largest child, got ' .. tostring(cache.items[1].name))
+assert(cache.items[1].label == '1.1G', 'its label, got ' .. tostring(cache.items[1].label))
+assert(cache.items[2].name == 'pip', 'second child, got ' .. tostring(cache.items[2].name))
+
+-- Sizes are compared as bytes, which is what makes 'biggest' meaningful.
+assert(cache.items[1].bytes > cache.items[2].bytes, '1.1G must outrank 245M')
+assert(cache.items[1].bytes > 1e9, 'a G suffix is about a billion bytes')
+assert(cache.items[4].bytes < 1e8, 'an M suffix is far smaller')
+
+-- Share of the total drives the highlight colour.
+assert(cache.items[1].bytes / cache.total.bytes > 0.5,
+ 'mozilla dominates this fixture, which is what the highlight keys on')
+
+assert(data.du_parse('') == nil, 'empty input gives nil')
+assert(data.du_parse(nil) == nil, 'nil gives nil')
+
print('test_data: all assertions passed')