diff --git a/cmd/things/testmain_test.go b/cmd/things/testmain_test.go new file mode 100644 index 0000000..6db6a6a --- /dev/null +++ b/cmd/things/testmain_test.go @@ -0,0 +1,18 @@ +package main + +import ( + "os" + "testing" + + "github.com/ryanlewis/things-cli/internal/output" +) + +// TestMain pins a deterministic no-color baseline for the cmd/things tests. +// These tests drive the styled output path via ctx.Run without going through +// main() (which is what calls output.SetColorMode), so without this the +// package-global color profile would be whatever colorprofile.Detect(os.Stdout) +// returned at import time — TTY-dependent and non-deterministic across runners. +func TestMain(m *testing.M) { + _ = output.SetColorMode("never") + os.Exit(m.Run()) +} diff --git a/go.mod b/go.mod index d835d43..74cd88e 100644 --- a/go.mod +++ b/go.mod @@ -3,28 +3,32 @@ module github.com/ryanlewis/things-cli go 1.26.1 require ( + charm.land/lipgloss/v2 v2.0.3 github.com/alecthomas/kong v1.15.0 - github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/colorprofile v0.4.3 github.com/mattn/go-isatty v0.0.22 - github.com/muesli/termenv v0.16.0 golang.org/x/term v0.43.0 modernc.org/sqlite v1.50.1 ) require ( - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.44.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index c8283fb..a78d329 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,27 @@ +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI= github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -26,43 +32,38 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= -modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= -modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= -modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= -modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= @@ -71,20 +72,16 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= -modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= -modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= diff --git a/internal/output/output.go b/internal/output/output.go index ca020d1..e86da40 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -6,7 +6,7 @@ import ( "io" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/ryanlewis/things-cli/internal/model" ) @@ -15,17 +15,20 @@ func Print(w io.Writer, v any, asJSON bool) error { if asJSON { return printJSON(w, v) } + // Styled output renders full-fidelity ANSI; downsample/strip it on the way + // out according to the active color profile. JSON carries no ANSI, so it is + // written to the raw writer. switch val := v.(type) { case []model.Task: - return printTasks(w, val) + return printTasks(newWriter(w), val) case *model.Task: - return printTaskDetail(w, val, nil) + return printTaskDetail(newWriter(w), val, nil) case []model.Project: - return printProjects(w, val) + return printProjects(newWriter(w), val) case []model.Area: - return printAreas(w, val) + return printAreas(newWriter(w), val) case []model.Tag: - return printTags(w, val) + return printTags(newWriter(w), val) default: return printJSON(w, v) } @@ -39,7 +42,7 @@ func PrintTaskWithChecklist(w io.Writer, t *model.Task, items []model.ChecklistI } return printJSON(w, taskWithChecklist{Task: t, Checklist: items}) } - return printTaskDetail(w, t, items) + return printTaskDetail(newWriter(w), t, items) } func printJSON(w io.Writer, v any) error { diff --git a/internal/output/output_test.go b/internal/output/output_test.go index f4fafa8..c7ac82f 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -193,6 +193,29 @@ func TestPrintTaskDetail(t *testing.T) { } } +func TestPrintTaskDetail_StripsAnsiInUserContent(t *testing.T) { + // Untrusted task content (from the Things DB) is routed through the same + // colorprofile.Writer as styled output, so literal ANSI escapes embedded in a + // note are stripped under --color=never / non-TTY instead of being injected + // into the terminal. TestMain pins "never". + task := &model.Task{ + UUID: "u1", + Title: "T1", + Notes: "before\x1b[31mRED\x1b[mafter", + } + var buf bytes.Buffer + if err := PrintTaskWithChecklist(&buf, task, nil, false); err != nil { + t.Fatalf("PrintTaskWithChecklist: %v", err) + } + out := buf.String() + if strings.Contains(out, "\x1b[") { + t.Errorf("expected ANSI stripped from note content, got %q", out) + } + if !strings.Contains(out, "beforeREDafter") { + t.Errorf("expected note text preserved, got %q", out) + } +} + func TestPrintTaskDetailJSON(t *testing.T) { task := &model.Task{UUID: "u1", Title: "T1", Deadline: mustDate(2026, 5, 20)} items := []model.ChecklistItem{{UUID: "c1", Title: "step", Status: model.StatusOpen}} diff --git a/internal/output/style.go b/internal/output/style.go index 4746676..ad64ca0 100644 --- a/internal/output/style.go +++ b/internal/output/style.go @@ -2,36 +2,58 @@ package output import ( "fmt" + "io" "os" "strings" "time" - "github.com/charmbracelet/lipgloss" - "github.com/muesli/termenv" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" "golang.org/x/term" "github.com/ryanlewis/things-cli/internal/model" ) -// SetColorMode reconfigures lipgloss's default renderer based on the user's -// --color flag. "auto" defers to lipgloss/termenv, which detects TTY and -// honours NO_COLOR. "always" forces TrueColor; "never" forces Ascii. +// colorProfile controls how the ANSI emitted by lipgloss renders is downsampled +// when written out. Lipgloss v2 styles always render full-fidelity ANSI; the +// stripping/downsampling that v1 did inside Render now happens at write time via +// a colorprofile.Writer (see newWriter). It is set by SetColorMode and defaults +// to auto-detection from stdout (honouring NO_COLOR, CLICOLOR_FORCE, etc.). +var colorProfile = detectProfile() + +// detectProfile auto-detects the color profile from stdout and the environment +// (honouring NO_COLOR, CLICOLOR_FORCE, etc.). Used for both the package default +// and the "auto" mode so the two can't drift. +func detectProfile() colorprofile.Profile { + return colorprofile.Detect(os.Stdout, os.Environ()) +} + +// SetColorMode reconfigures color output based on the user's --color flag. +// "auto" detects from stdout/env, "always" forces TrueColor, "never" strips all +// ANSI. func SetColorMode(mode string) error { switch mode { case "", "auto": - // no-op — lipgloss self-detects from os.Stdout + colorProfile = detectProfile() case "always": - lipgloss.SetColorProfile(termenv.TrueColor) + colorProfile = colorprofile.TrueColor case "never": - lipgloss.SetColorProfile(termenv.Ascii) + colorProfile = colorprofile.NoTTY default: return fmt.Errorf("invalid --color mode %q (want auto|always|never)", mode) } return nil } -// Palette. Lipgloss strips ANSI when writing to non-TTY, so existing -// substring-based tests remain valid. +// newWriter wraps w so that the ANSI emitted by lipgloss renders is downsampled +// (or stripped entirely) according to the active color profile on the way out. +func newWriter(w io.Writer) *colorprofile.Writer { + return &colorprofile.Writer{Forward: w, Profile: colorProfile} +} + +// Palette. Styles render full-fidelity ANSI unconditionally; the +// colorprofile.Writer applied in Print strips it for non-TTY / --color=never +// output, so substring-based tests over Print remain valid. var ( statusOpenStyle = lipgloss.NewStyle() statusDoneStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Faint(true) diff --git a/internal/output/style_test.go b/internal/output/style_test.go index a5e8d28..dc3e283 100644 --- a/internal/output/style_test.go +++ b/internal/output/style_test.go @@ -1,16 +1,29 @@ package output import ( + "bytes" + "os" "strings" "testing" "time" - "github.com/charmbracelet/lipgloss" - "github.com/muesli/termenv" + "charm.land/lipgloss/v2" "github.com/ryanlewis/things-cli/internal/model" ) +// TestMain forces a deterministic no-color baseline for the output package +// tests. Lipgloss v2 renders full-fidelity ANSI unconditionally; stripping +// happens at write time according to the active color profile (see style.go). +// The default profile is auto-detected from os.Stdout, so without this the +// layout/content assertions would depend on whether the test process is +// attached to a TTY (interactive/PTY runner) or a pipe (CI). Color behavior is +// covered explicitly by TestColorMode_* which set their own mode. +func TestMain(m *testing.M) { + _ = SetColorMode("never") + os.Exit(m.Run()) +} + func TestSetColorMode(t *testing.T) { t.Cleanup(func() { _ = SetColorMode("never") }) @@ -35,50 +48,113 @@ func TestSetColorMode(t *testing.T) { } } -func TestStyledStatus_NeverMode(t *testing.T) { - prev := lipgloss.ColorProfile() - t.Cleanup(func() { lipgloss.SetColorProfile(prev) }) +// In v2, styles always render full-fidelity ANSI; stripping/downsampling happens +// at write time via the color profile. These tests therefore assert on the bytes +// that reach the writer (via Print), not on the raw output of style helpers. +func TestColorMode_Never_StripsANSI(t *testing.T) { + t.Cleanup(func() { _ = SetColorMode("never") }) if err := SetColorMode("never"); err != nil { t.Fatal(err) } - got := styledStatus(model.StatusCompleted) - if strings.Contains(got, "\x1b[") { - t.Errorf("expected no ANSI escapes in never mode, got %q", got) + var buf bytes.Buffer + tasks := []model.Task{{UUID: "u1", Title: "Done", Status: model.StatusCompleted}} + if err := Print(&buf, tasks, false); err != nil { + t.Fatal(err) + } + out := buf.String() + if strings.Contains(out, "\x1b[") { + t.Errorf("expected no ANSI escapes in never mode, got %q", out) + } + if !strings.Contains(out, "[x]") { + t.Errorf("expected glyph [x] in output, got %q", out) + } +} + +func TestColorMode_Always_EmitsANSI(t *testing.T) { + t.Cleanup(func() { _ = SetColorMode("never") }) + if err := SetColorMode("always"); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + // The tag is styled with a pure foreground color (SGR 33), unlike the + // completed title which carries only faint/strikethrough decoration. Asserting + // the tag's color sequence proves always-mode emits *color*, not merely text + // decoration that would survive an accidental downsample to ASCII. + tasks := []model.Task{{UUID: "u1", Title: "Done", Status: model.StatusCompleted, Tags: []string{"tag"}}} + if err := Print(&buf, tasks, false); err != nil { + t.Fatal(err) + } + if out := buf.String(); !strings.Contains(out, "\x1b[33m") { + t.Errorf("expected the tag color (SGR 33) in always mode, got %q", out) + } +} + +func TestColorMode_Always_TaskDetail(t *testing.T) { + t.Cleanup(func() { _ = SetColorMode("never") }) + if err := SetColorMode("always"); err != nil { + t.Fatal(err) + } + // The detail view styles tags with a pure foreground color (SGR 33); assert + // it survives the always-mode (TrueColor) writer on the PrintTaskWithChecklist + // path, which is distinct from the printTasks path. + task := &model.Task{UUID: "u1", Title: "T1", Tags: []string{"tag"}} + var buf bytes.Buffer + if err := PrintTaskWithChecklist(&buf, task, nil, false); err != nil { + t.Fatal(err) } - if !strings.Contains(got, "[x]") { - t.Errorf("expected glyph [x] in output, got %q", got) + if out := buf.String(); !strings.Contains(out, "\x1b[33m") { + t.Errorf("expected the tag color (SGR 33) in always-mode detail, got %q", out) } } -func TestStyledStatus_AlwaysMode(t *testing.T) { - prev := lipgloss.ColorProfile() +func TestColorMode_Auto_StripsWhenNonTTY(t *testing.T) { + // "auto" detects from os.Stdout. Point stdout at a pipe (a non-TTY) and clear + // any color-forcing env so detection is deterministic, then confirm auto + // strips ANSI — the `things ... | cat` / redirect-to-file path. This covers + // the default real-CLI branch (colorprofile.Detect), which the never/always + // tests bypass by pinning a static profile. + t.Setenv("CLICOLOR_FORCE", "") + t.Setenv("NO_COLOR", "") + t.Setenv("TTY_FORCE", "") + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + orig := os.Stdout + os.Stdout = w t.Cleanup(func() { - lipgloss.SetColorProfile(prev) + os.Stdout = orig + _ = w.Close() + _ = r.Close() + _ = SetColorMode("never") }) - if err := SetColorMode("always"); err != nil { + if err := SetColorMode("auto"); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + tasks := []model.Task{{UUID: "u1", Title: "Done", Status: model.StatusCompleted}} + if err := Print(&buf, tasks, false); err != nil { t.Fatal(err) } - got := styledStatus(model.StatusCompleted) - if !strings.Contains(got, "\x1b[") { - t.Errorf("expected ANSI escapes in always mode, got %q", got) + out := buf.String() + if strings.Contains(out, "\x1b[") { + t.Errorf("auto mode on a non-TTY should strip ANSI, got %q", out) + } + if !strings.Contains(out, "[x]") { + t.Errorf("expected glyph [x] in output, got %q", out) } } func TestStyledDate_Buckets(t *testing.T) { prevNow := nowFn - prevProfile := lipgloss.ColorProfile() - t.Cleanup(func() { - nowFn = prevNow - lipgloss.SetColorProfile(prevProfile) - }) + t.Cleanup(func() { nowFn = prevNow }) // Pin "now" to 2026-05-03 (Sunday). nowFn = func() time.Time { return time.Date(2026, 5, 3, 12, 0, 0, 0, time.Local) } - lipgloss.SetColorProfile(termenv.TrueColor) cases := []struct { name string @@ -113,15 +189,15 @@ func TestStyledDate_Buckets(t *testing.T) { } func TestStyledTags(t *testing.T) { - prev := lipgloss.ColorProfile() - t.Cleanup(func() { lipgloss.SetColorProfile(prev) }) - _ = SetColorMode("never") - if got := styledTags(nil); got != "" { t.Errorf("empty tags should render empty, got %q", got) } + // In v2 styledTags always wraps the bracketed content in tag styling, so the + // expected value is that same render. Comparing against tagStyle.Render keeps + // v1's exact-equality scrutiny (catching a missing/extra bracket or stray + // content) without depending on the color mode. got := styledTags([]string{"a", "b"}) - if got != "[a, b]" { - t.Errorf("styledTags = %q, want %q", got, "[a, b]") + if want := tagStyle.Render("[a, b]"); got != want { + t.Errorf("styledTags = %q, want %q", got, want) } }