<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Enrico Testori</title>
    <description>The latest articles on DEV Community by Enrico Testori (@hypertesto).</description>
    <link>https://dev.to/hypertesto</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4029274%2Fa35cedbe-4c9f-4c2c-a53f-04776bc77d12.png</url>
      <title>DEV Community: Enrico Testori</title>
      <link>https://dev.to/hypertesto</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hypertesto"/>
    <language>en</language>
    <item>
      <title>Migrating an ancient SVN repo the painful way (tunnel vision included)</title>
      <dc:creator>Enrico Testori</dc:creator>
      <pubDate>Thu, 30 Jul 2026 13:50:21 +0000</pubDate>
      <link>https://dev.to/hypertesto/migrating-an-ancient-svn-repo-the-painful-way-tunnel-vision-included-3nke</link>
      <guid>https://dev.to/hypertesto/migrating-an-ancient-svn-repo-the-painful-way-tunnel-vision-included-3nke</guid>
      <description>&lt;p&gt;A while ago I inherited a ticking clock. The old internal server was finally being decommissioned, and sitting on it, alone by that point, like the last tenant of a building scheduled for demolition, was an SVN repository. 82,009 revisions. Fifteen years of commit history. 21 GB on disk. Set up by someone who left the company six years ago and has not been heard from since.&lt;/p&gt;

&lt;p&gt;The requirements were simple on paper and annoying in practice. My coworkers (and some internal tools) needed to keep using SVN afterwards (&lt;code&gt;svn switch --relocate&lt;/code&gt; and carry on with their lives), so migrating to git was off the table&lt;sup id="fnref1"&gt;1&lt;/sup&gt;. I had to produce a working SVN repository somewhere else, ideally a slimmer one, because fifteen years of history include a couple dozen directories full of large binaries that nobody has needed since approximately the previous geological era.&lt;/p&gt;

&lt;p&gt;What follows is the honest, chronological reconstruction of what happened next. I'm writing it as a series of attempts because that's literally what it was: each failure manufactured the problem that the next attempt would heroically fail to solve. And since I'm an engineer before I'm a storyteller, every attempt closes with its lab notes: what I wanted, what I fed in, and what came out. The outcome column is where the comedy lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt one: the obvious one
&lt;/h2&gt;

&lt;p&gt;The goal, at the beginning, was simple: get all 82,009 revisions out of that server and onto a disk I controlled. So I started where everyone starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;svnrdump dump https://svn.server/repo &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; full.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command, one file, one prayer. I knew it wasn't going to work (~82,000 revisions over a flaky connection means any hiccup sends you back to revision zero), but you have to try the obvious thing first, if only out of respect for tradition.&lt;/p&gt;

&lt;p&gt;I let it run overnight. It died at 4 a.m. somewhere in the low thirty-thousands, which I choose to read as the repo setting my expectations early.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Intent:&lt;/strong&gt; a complete dump of the repository, in a single file.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Input:&lt;/strong&gt; the repo URL, one &lt;code&gt;svnrdump&lt;/code&gt; command, blind optimism.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Outcome:&lt;/strong&gt; a partial dump (~30k revisions in), one dead process, nothing usable.&lt;/p&gt;
&lt;h2&gt;
  
  
  Attempt two: divide and conquer
&lt;/h2&gt;

&lt;p&gt;Fine. If the pipe won't survive six hours, don't run a six-hour pipe. The new plan: slice the dump into 2,000-revision chunks, compress each one as it lands, and track progress in a manifest file so the whole thing could resume from the last checkpoint instead of from revision zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;CHUNK_SIZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2000

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CURRENT_START&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LATEST_REMOTE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nv"&gt;END_REV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;CURRENT_START &lt;span class="o"&gt;+&lt;/span&gt; CHUNK_SIZE&lt;span class="k"&gt;))&lt;/span&gt;

    svnrdump dump &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REPO_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RANGE_START&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;END_REV&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CHUNK_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt;

    &lt;span class="c"&gt;# only on success: promote the chunk and checkpoint&lt;/span&gt;
    &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CHUNK_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CHUNK_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$END_REV&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MANIFEST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;.tmp&lt;/code&gt; dance: a chunk only earns its real name once &lt;code&gt;gzip&lt;/code&gt; closes cleanly, so a killed run never leaves a half-written file masquerading as a good one. I was learning. Slowly, and at great cost, but learning.&lt;/p&gt;

&lt;p&gt;And it &lt;em&gt;worked&lt;/em&gt;. The network could drop, the process could be killed, I could free disk between iterations, and the script just resumed from the manifest. By the end of the day I had, for the first time, a complete dump of the repository: forty-one gzipped chunks covering every revision from zero to 82,009.&lt;/p&gt;

&lt;p&gt;The victory lasted about ten minutes: roughly the time it took to run &lt;code&gt;du -sh&lt;/code&gt;. The output was too large for the destination server's disk. Which meant I now needed to filter the dump. And that's where the real trouble started, though I didn't know it yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Intent:&lt;/strong&gt; a dump that survives network drops, OOMs and disk-pressure, resumable by construction.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Input:&lt;/strong&gt; the repo URL, &lt;code&gt;CHUNK_SIZE=2000&lt;/code&gt;, one manifest file as checkpoint.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Outcome:&lt;/strong&gt; complete success, technically: 41 chunks, all revisions present, total size larger than the destination disk. Also, though I didn't know it yet, every chunk was a self-contained time bomb. More on that shortly.&lt;/p&gt;
&lt;h2&gt;
  
  
  Attempt three: divide, conquer, filter
&lt;/h2&gt;

&lt;p&gt;The natural next move was splicing &lt;code&gt;svndumpfilter exclude&lt;/code&gt; into the pipeline, dropping ~20 paths that were no longer needed&lt;sup id="fnref2"&gt;2&lt;/sup&gt;: old project archives, documentation builds from 2011, subprojects that had long since been split into their own repos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;EXCLUDES&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
    &lt;span class="s2"&gt;"/research_project3.1518684973-ver3.9-FINAL.pdf"&lt;/span&gt;
    &lt;span class="s2"&gt;"/P1-P2-Help-Desk(chunking).docx"&lt;/span&gt;
    &lt;span class="s2"&gt;"/com.acme.textprocessing.a"&lt;/span&gt;
    &lt;span class="s2"&gt;"/com.acme.textprocessing.b"&lt;/span&gt;
    &lt;span class="s2"&gt;"/com.acme.textprocessing.c"&lt;/span&gt;
    &lt;span class="s2"&gt;"/com.acme.textprocessing.d"&lt;/span&gt;
    &lt;span class="s2"&gt;"/legacyDoc"&lt;/span&gt;
    &lt;span class="s2"&gt;"/legacy-utils"&lt;/span&gt;
    &lt;span class="s2"&gt;"/customerX"&lt;/span&gt;
    &lt;span class="c"&gt;# ... ~20 entries total&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;

svnrdump dump &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REPO_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RANGE_START&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;END_REV&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
    svndumpfilter exclude &lt;span class="nv"&gt;$EXCLUDE_ARGS&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CHUNK_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It failed at the chunk boundaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;svndumpfilter: E200003: Invalid copy source path '/legacy-utils/trunk/...' for '/SomeOtherProject/trunk/...'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two discoveries, neither pleasant. First: &lt;code&gt;svndumpfilter&lt;/code&gt; matches path &lt;em&gt;prefixes&lt;/em&gt; only: no wildcards, no regex, no substring matching. &lt;code&gt;com.acme.textprocessing.a&lt;/code&gt; is not considered "inside" &lt;code&gt;com.acme.textprocessing&lt;/code&gt;, because SVN treats dots literally, not as directory separators. Second, and much worse: if revision 8,342 copies a file &lt;em&gt;from&lt;/em&gt; a path you're excluding &lt;em&gt;to&lt;/em&gt; a path you're keeping, the dump stream references a source that no longer exists. &lt;code&gt;svndumpfilter&lt;/code&gt; hits it, stops, and provides no &lt;code&gt;--skip-broken-references&lt;/code&gt; flag. No recovery, no partial output.&lt;/p&gt;

&lt;p&gt;Fifteen years of directory moves, project renames, and module splits meant the copy-from graph was deeply tangled. I could either abandon filtering, or understand the dependency structure well enough to navigate around it.&lt;/p&gt;

&lt;p&gt;I chose poorly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Intent:&lt;/strong&gt; same resumable chunked dump, minus ~20 directories of dead weight.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Input:&lt;/strong&gt; the attempt-two pipeline with &lt;code&gt;svndumpfilter exclude&lt;/code&gt; spliced in, plus one lovingly curated exclude list.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Outcome:&lt;/strong&gt; &lt;code&gt;E200003&lt;/code&gt; at chunk boundaries, zero usable output, and two unpleasant facts about &lt;code&gt;svndumpfilter&lt;/code&gt; learned the hard way.&lt;br&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Attempt four: the Python era
&lt;/h2&gt;

&lt;p&gt;This was around day three, and the point where a backup migration quietly turned into a software project. The plan: before touching &lt;code&gt;svndumpfilter&lt;/code&gt; again, map the entire copy-from graph of the repository and find out, with data rather than hope, whether any exclude set could ever work.&lt;/p&gt;

&lt;p&gt;I wrote &lt;code&gt;analyze_svn_chunks.py&lt;/code&gt;, a binary-streaming scanner that reads every chunk file's &lt;code&gt;Node-path&lt;/code&gt;, &lt;code&gt;Node-copyfrom-path&lt;/code&gt;, and &lt;code&gt;Revision-number&lt;/code&gt; headers and builds a global map of every copy-from dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;REV_RE&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^Revision-number:\s*(\d+)$&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;NODE_PATH&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^Node-path:\s*(.+)$&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;NODE_COPYFROM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^Node-copyfrom-path:\s*(.+)$&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sorted_chunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;iter_lines_binary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# track first_seen[path], path_locations, copyfrom_refs...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was later joined by two reconnaissance siblings: &lt;code&gt;find_copyfrom_prefixes.py&lt;/code&gt;, which lists every copy-from source sitting under the prefixes I wanted to exclude, and &lt;code&gt;find_svn_copy_nodes.py&lt;/code&gt;, which flags copy nodes that a given include/exclude list would leave orphaned. I was no longer migrating a repository; I was running a small research institute.&lt;/p&gt;

&lt;p&gt;The findings: &lt;strong&gt;8,172 unique copy-from sources&lt;/strong&gt;, summarized in four report files: &lt;code&gt;copyfrom_sources.txt&lt;/code&gt;, &lt;code&gt;copyfrom_report.json&lt;/code&gt;, &lt;code&gt;missing_sources.txt&lt;/code&gt;, and, critically, &lt;code&gt;sources_in_excludes.txt&lt;/code&gt;: copy-from paths sitting &lt;em&gt;inside&lt;/em&gt; the very directories I wanted to exclude. Dozens of them. There's a special kind of sinking feeling reserved for the moment a tool you wrote outputs formal proof that your plan cannot work. &lt;code&gt;svndumpfilter&lt;/code&gt; was structurally incapable of handling this exclude set, full stop.&lt;/p&gt;

&lt;p&gt;Fine, I thought: filter later, load first. Except even the &lt;em&gt;unfiltered&lt;/em&gt; chunks refused to load into a test repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;svnadmin: E160020: File already exists: filesystem '...', path '/acmemmf'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason, once I dug in: every chunk was self-contained. When a new chunk started at revision 5001, &lt;code&gt;svnrdump&lt;/code&gt; re-emitted &lt;code&gt;Node-action: add&lt;/code&gt; for every file that already existed in the repository. &lt;code&gt;svnadmin load&lt;/code&gt; would see a path it had already created from the previous chunk and crash. So I wrote a second kind of tool, &lt;code&gt;svn_dump_repair.py&lt;/code&gt;, to rewrite the duplicate adds into changes, tracking path state in a dictionary shared across all chunks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;repair_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# existing: cross-chunk shared dict {path(bytes) -&amp;gt; first_rev(bytes)}
&lt;/span&gt;    &lt;span class="c1"&gt;# streaming binary parse, track cur_path/cur_action,
&lt;/span&gt;    &lt;span class="c1"&gt;# if cur_action == b'add' and cur_path in existing:
&lt;/span&gt;    &lt;span class="c1"&gt;#     replace ACTION_ADD with ACTION_CHG
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And this is where things got &lt;em&gt;properly&lt;/em&gt; worse, because the first version opened the dump with &lt;code&gt;encoding='utf-8', errors='replace'&lt;/code&gt;. SVN dump files contain raw binary diffs. The &lt;code&gt;errors='replace'&lt;/code&gt; setting silently swapped in replacement characters for invalid bytes. In other words, my repair tool was corrupting the very diffs it was supposed to preserve. Loading the "repaired" chunks gave:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;svnadmin: E185001: Svndiff contains a too-large window
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's probably a metaphor in there about fixing things by breaking them harder, but I was too busy to look for it. I rewrote the script to process everything in raw binary mode (&lt;code&gt;rb&lt;/code&gt;/&lt;code&gt;wb&lt;/code&gt;), touching only the header lines it actually needed to inspect. Binary pass-through for everything else. (The docstring of the final version still carries the scar: &lt;em&gt;"Processes everything in BINARY mode to avoid corrupting SVN diff data."&lt;/em&gt;)&lt;/p&gt;

&lt;p&gt;Only then did I find the real root cause, and it stung: I wasn't using &lt;code&gt;--incremental&lt;/code&gt;. The dump format has no primitive for "the repository already has this path from a previous load." Every chunked dump without &lt;code&gt;--incremental&lt;/code&gt; is a self-contained stream that re-describes the entire filesystem state at its starting revision. The first chunk needs a full dump; every subsequent chunk must be &lt;code&gt;--incremental&lt;/code&gt;. The 8,172 copy-from sources, the E160020 crashes at chunk boundaries: the same underlying problem wearing different hats. A small zoo of Python scripts, and the fix was a flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Intent:&lt;/strong&gt; first, map the copy-from web to find a viable exclude set; then, bend the chunks until they loaded in sequence.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Input:&lt;/strong&gt; chunk files + &lt;code&gt;excludes.txt&lt;/code&gt; into the analyzer; chunk files + one shared cross-chunk state dict into the repair tool.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Outcome:&lt;/strong&gt; a JSON file formally proving my filtering plan impossible; a repair tool that first corrupted the data (E185001), then worked, then became unnecessary the moment &lt;code&gt;--incremental&lt;/code&gt; entered my life.&lt;/p&gt;
&lt;h2&gt;
  
  
  Attempt five: things get worse before they get... no, they just get worse
&lt;/h2&gt;

&lt;p&gt;This was day four. Or five. I'd stopped counting, and had started naming scripts things like &lt;code&gt;fix_dump_FINAL.py&lt;/code&gt;, which anyone will recognize as a bad omen.&lt;/p&gt;

&lt;p&gt;The new, corrected pipeline: first chunk full, every subsequent chunk dumped with &lt;code&gt;--incremental&lt;/code&gt;, filtering removed entirely. Surely, &lt;em&gt;surely&lt;/em&gt;, loading the unfiltered chunks would now work. It did not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;svnadmin: E160013: File not found: transaction '...', path '/com.acme.esb/...'
svnadmin: E200014: Base checksum mismatch on '/acmemmf/trunk/...'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These were copy-from references pointing at source revisions that weren't present in the chunks loaded so far, because some earlier chunk had failed silently along the way. Files copied from revision 17,711 into revision 66,669, with revision 17,711 nowhere to be found. The chunking strategy wasn't just fragile; it was fragile &lt;em&gt;and&lt;/em&gt; quiet about it, which is the worst combination.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Intent:&lt;/strong&gt; load the unfiltered, properly-incremental chunks and finally have a working local copy.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Input:&lt;/strong&gt; the fresh &lt;code&gt;--incremental&lt;/code&gt; chunk set.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Outcome:&lt;/strong&gt; &lt;code&gt;E160013&lt;/code&gt; and &lt;code&gt;E200014&lt;/code&gt;: references to source revisions that never made it into the dump, courtesy of earlier silent failures.&lt;/p&gt;
&lt;h2&gt;
  
  
  Attempt six: one dump to rule them all
&lt;/h2&gt;

&lt;p&gt;New strategy, and I want you to know it felt clever at the time: dump the entire repo unfiltered, in one logical pass, with &lt;code&gt;--incremental&lt;/code&gt; on everything except the first chunk. Concatenate all chunks locally into a single stream. Then run &lt;code&gt;svndumpfilter&lt;/code&gt; exactly once, on the combined file. Two passes, clean separation of concerns. What could possibly…&lt;/p&gt;

&lt;p&gt;The concatenated unfiltered dump came out to &lt;strong&gt;23 GB&lt;/strong&gt; compressed.&lt;/p&gt;

&lt;p&gt;I ran the second pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zcat filtered_1st_pass.dump.gz | &lt;span class="se"&gt;\&lt;/span&gt;
    svndumpfilter exclude &lt;span class="nt"&gt;--drop-empty-revs&lt;/span&gt; &lt;span class="nv"&gt;$EXCL_ARGS&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; filtered_2nd_pass.dump.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the answer was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;svndumpfilter: E140001: Dumpstream data appears to be malformed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-three gigabytes of compressed history, and the verdict was: &lt;em&gt;malformed&lt;/em&gt;. No offset. No line number. No partial output. No &lt;code&gt;--skip-malformed&lt;/code&gt; flag to reach for. The process stopped, took its ball, and went home.&lt;/p&gt;

&lt;p&gt;That was the breaking point. I closed the terminal, made a coffee, and admitted to myself that I had just lost a week-long fight against a text file format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Intent:&lt;/strong&gt; one complete logical dump, filtered exactly once, clean separation of concerns.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Input:&lt;/strong&gt; all chunks concatenated into a single 23 GB compressed stream.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Outcome:&lt;/strong&gt; &lt;code&gt;E140001&lt;/code&gt;, no offset, no mercy. One (1) coffee of defeat.&lt;/p&gt;
&lt;h2&gt;
  
  
  The light at the end of the tunnel was... a tunnel
&lt;/h2&gt;

&lt;p&gt;Defeated people do honest things, so I did what I should have done on day one: I stopped skimming the SVN Book for flags and actually &lt;em&gt;read&lt;/em&gt; it. And there it was, sitting in plain sight the whole time: &lt;code&gt;svnsync&lt;/code&gt;, SVN's built-in mirroring tool.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;svnsync&lt;/code&gt; replicates a repository revision by revision over the network using SVN's own protocol. No dump files. No filtering. No manual parsing of &lt;code&gt;Node-copyfrom-path&lt;/code&gt; headers. Copy-from references, &lt;code&gt;svn:mergeinfo&lt;/code&gt;, property changes. All first-class concepts in the wire protocol, handled natively.&lt;/p&gt;

&lt;p&gt;I pointed it at the same &lt;code&gt;https://&lt;/code&gt; endpoint that &lt;code&gt;svnrdump&lt;/code&gt; had been talking to all along, and at first it felt like the happy ending. It resumed after connection drops. It chugged along, revision by revision. And then the checksums started failing: about a dozen revisions came out corrupted. I wiped the mirror, retried, and got the same dozen, give or take, and they were always the heavy ones, the revisions carrying the big binaries.&lt;/p&gt;

&lt;p&gt;The culprit wasn't SVN. It was Apache. Over &lt;code&gt;https://&lt;/code&gt;, Subversion doesn't speak its native protocol at all: it speaks WebDAV/DeltaV through &lt;code&gt;mod_dav_svn&lt;/code&gt;, which lives inside an Apache worker process. Every operation becomes an HTTP request with an XML payload, and everything runs under the web server's resource governance: per-process memory limits, request timeouts, MPM tuning. When my sync pushed a multi-hundred-megabyte delta through, the httpd child handling it ballooned, hit its memory ceiling, and died mid-stream. What landed in my mirror was a truncated revision and a checksum mismatch.&lt;/p&gt;

&lt;p&gt;The obvious fix was to raise Apache's memory limit and try again. But blindly bumping a limit you don't understand just moves the wall a bit further: the next fatter binary would have found the new ceiling eventually. So I finally used my so-called brain and asked a better question: what is a web server doing in the middle of my data transfer at all?&lt;/p&gt;

&lt;p&gt;Because, and this is the part I find genuinely interesting, SVN wasn't born speaking &lt;code&gt;svn://&lt;/code&gt;. The project started life as an Apache module; WebDAV was the &lt;em&gt;original&lt;/em&gt; transport, and &lt;code&gt;svnserve&lt;/code&gt; only arrived later, for people who didn't feel like running an entire web server just to version their code. The two transports are wildly different animals:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;https://&lt;/code&gt; (mod_dav_svn)&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;svn://&lt;/code&gt; (svnserve)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transport&lt;/td&gt;
&lt;td&gt;HTTP requests with XML payloads (WebDAV/DeltaV)&lt;/td&gt;
&lt;td&gt;Purpose-built binary protocol over a stateful TCP connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server process&lt;/td&gt;
&lt;td&gt;Apache worker, subject to its memory and timeout limits&lt;/td&gt;
&lt;td&gt;Small dedicated daemon with a modest, predictable footprint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payload&lt;/td&gt;
&lt;td&gt;Native deltas wrapped in HTTP/XML envelopes&lt;/td&gt;
&lt;td&gt;Native deltas, streamed as-is&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Middlemen&lt;/td&gt;
&lt;td&gt;A full web server that may decide your 2 GB delta looks like an attack&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In short: &lt;code&gt;https://&lt;/code&gt; makes SVN traffic look like web traffic: fantastic for slipping through corporate proxies, terrible for shoveling gigabytes of binary history from A to B. &lt;code&gt;svn://&lt;/code&gt; is the protocol SVN speaks when nobody forces it to wear a costume.&lt;/p&gt;

&lt;p&gt;And here's the part that still makes me laugh. As far as I know, this repository has been served through Apache for its entire fifteen-year existence, most of it on a machine considerably weaker than the one it retired from. Which means every single one of those monster binaries went &lt;em&gt;in&lt;/em&gt; through the very same web server that was now fainting at the sight of them coming out&lt;sup id="fnref3"&gt;3&lt;/sup&gt;. Committing, apparently, was perfectly fine. Reading back was where the web server drew the line.&lt;/p&gt;

&lt;p&gt;To be fair, there is a real asymmetry: on commit, the client does the heavy lifting and hands the server a ready-made delta to store; on read, &lt;code&gt;mod_dav_svn&lt;/code&gt; has to reconstruct and stream those deltas itself, on its own memory dime. And fifteen years of commits arrived one polite transaction at a time, over a LAN, with nobody in a hurry. I showed up asking for everything, all at once, the digital equivalent of draining a swimming pool through the same straw people had used to fill it.&lt;/p&gt;

&lt;p&gt;The catch: the source server didn't expose &lt;code&gt;svn://&lt;/code&gt; to the network. It sat on an internal subnet reachable only through a jump host. But the jump host had SSH, and SSH is the universal solvent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-L&lt;/span&gt; 3690:10.0.0.3:3690 svn-machine

&lt;span class="c"&gt;# in another terminal:&lt;/span&gt;
svnsync init file:///tmp/svn_dest_repo svn://localhost:3690
svnsync &lt;span class="nb"&gt;sync &lt;/span&gt;file:///tmp/svn_dest_repo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three commands. I typed them with the confidence of a man who had already been betrayed four times that week, and waited.&lt;/p&gt;

&lt;p&gt;It just worked. Revision after revision, ticking upward, unbothered. When the connection dropped (of course it dropped), &lt;code&gt;svnsync sync&lt;/code&gt; simply resumed from the last checkpointed revision. The SSH tunnel (&lt;code&gt;-L local_port:remote_host:remote_port&lt;/code&gt;) gave me a stable, encrypted pipe through the firewall: &lt;code&gt;localhost:3690&lt;/code&gt; on my machine mapped to &lt;code&gt;10.0.0.3:3690&lt;/code&gt; on the source, with the jump host doing the routing. The two servers never needed to see each other at all.&lt;/p&gt;

&lt;p&gt;The whole 21 GB came across, binaries and all, and this time the verification pass came back clean. All 82,009 revisions, not one byte out of place. The same tool that had mangled a dozen revisions over DAV didn't put a single one wrong over &lt;code&gt;svn://&lt;/code&gt;&lt;sup id="fnref4"&gt;4&lt;/sup&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Intent:&lt;/strong&gt; replicate the repository using SVN's own protocol: no dumps, no filters, no text parsing.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Input, take one:&lt;/strong&gt; &lt;code&gt;svnsync&lt;/code&gt; over the existing &lt;code&gt;https://&lt;/code&gt; endpoint → a dozen corrupted revisions, always the heavy ones (Apache OOM mid-delta).&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Input, take two:&lt;/strong&gt; &lt;code&gt;svnsync&lt;/code&gt; over &lt;code&gt;svn://&lt;/code&gt; through an SSH tunnel → 82,009 revisions, verification clean, zero corrupted. One week of my life not coming back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the boring tool won
&lt;/h2&gt;

&lt;p&gt;In hindsight, the dump pipeline and &lt;code&gt;svnsync&lt;/code&gt; attack the same problem from opposite directions. The pipeline is a serial, single-shot transformation chain: &lt;code&gt;svnrdump&lt;/code&gt; reads raw repo data, &lt;code&gt;svndumpfilter&lt;/code&gt; removes paths by parsing text, &lt;code&gt;gzip&lt;/code&gt; compresses bytes. Each tool speaks a different abstraction level, and any failure anywhere kills the whole operation, usually silently, occasionally at 4 a.m.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;svnsync&lt;/code&gt; never leaves SVN's own protocol, at least not once you let it use the native one. It negotiates revisions, transfers native deltas, checkpoints atomically. It doesn't parse dump headers because it doesn't need dump headers; it doesn't trip over copy-from references because the protocol resolves them for it. And it &lt;em&gt;resumes&lt;/em&gt;, which, for an 82,000-revision transfer, is the entire difference between a tool and a gamble.&lt;/p&gt;

&lt;p&gt;Though, as the corrupted dozen taught me, even the right tool needs the right transport. Every layer you stack between the two repositories (HTTP, XML, a web server with opinions about memory) is another place where your data can get quietly truncated.&lt;/p&gt;

&lt;p&gt;The irony is not subtle: &lt;code&gt;svnsync&lt;/code&gt; is the tool the SVN documentation recommends for mirroring. It was there from the start. I just had to fail with chunked dumps, a growing pile of Python scripts, and a 23 GB malformed file before I was emotionally ready to accept it&lt;sup id="fnref5"&gt;5&lt;/sup&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Epilogue
&lt;/h2&gt;

&lt;p&gt;The mirror completed. The old server, at the time of writing, is still standing, but its successor is ready, and keeping it warm until the cutover costs exactly one command: &lt;code&gt;svnsync sync&lt;/code&gt;, the very same one, which simply fetches whatever revisions landed in the meantime. Put it in a cron job and the mirror maintains itself while everyone keeps committing to the old repo, blissfully unaware.&lt;br&gt;&lt;br&gt;
Somewhere on a disk there's still a directory of chunked dump files, four Python scripts, and a 23 GB compressed artifact that &lt;code&gt;svndumpfilter&lt;/code&gt; once called malformed, none of which I will ever touch again, which is exactly the right fate for tools written in desperation&lt;sup id="fnref6"&gt;6&lt;/sup&gt;.&lt;/p&gt;

&lt;p&gt;I spent a week engineering increasingly elaborate solutions to problems that only existed because of the approach I'd chosen. The answer was never better filtering, better chunking, or better binary parsing. It was to stop fighting the dump format and use the protocol instead.&lt;/p&gt;

&lt;p&gt;Also: SSH tunnels are magic, &lt;code&gt;svnsync&lt;/code&gt; deserves far more respect than its own documentation gives it, and the next person to commit a multi-gigabyte binary to version control will be judged: silently, professionally, but intensely.&lt;/p&gt;

&lt;p&gt;Thanks for &lt;del&gt;dumping&lt;/del&gt; reading!&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;I did explore git briefly, mid-crisis, like a man checking the price of flights during a family dinner. The &lt;code&gt;git-filter-repo&lt;/code&gt; tool is &lt;em&gt;vastly&lt;/em&gt; better at path-based filtering than anything SVN offers, but the deliverable was a working SVN endpoint. Technical debt, eh?&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;Yes, one of the files I was trying to exclude was literally named &lt;code&gt;Help-Desk(chunking).docx&lt;/code&gt;. Fifteen years in advance, the repo was already mocking my strategy.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;The files checked in any time they liked, but they could never leave.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn4"&gt;
&lt;p&gt;Yes, unfiltered: the poisoned directories came along for the ride. Disk is cheap. My sanity, at that point, was not.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn5"&gt;
&lt;p&gt;If you're reading this because you're staring down your own ancient SVN migration: try &lt;code&gt;svnsync&lt;/code&gt; first. The dump pipeline is a trap. Do not be me.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn6"&gt;
&lt;p&gt;If there's a silver lining, it's that a week of poking compressed SVN dumps makes you genuinely fluent in &lt;code&gt;zcat&lt;/code&gt;, &lt;code&gt;zgrep&lt;/code&gt;, &lt;code&gt;zless&lt;/code&gt;, &lt;code&gt;gzip -t&lt;/code&gt;, and the fine art of binary-safe stream processing. Not skills I wake up wanting to practice, but surprisingly transferable.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>backup</category>
      <category>systems</category>
      <category>svn</category>
      <category>linux</category>
    </item>
    <item>
      <title>How to parse dates with optional parts with Java Instant API</title>
      <dc:creator>Enrico Testori</dc:creator>
      <pubDate>Tue, 21 Jul 2026 20:43:12 +0000</pubDate>
      <link>https://dev.to/hypertesto/how-to-parse-dates-with-optional-parts-with-java-instant-api-g61</link>
      <guid>https://dev.to/hypertesto/how-to-parse-dates-with-optional-parts-with-java-instant-api-g61</guid>
      <description>&lt;p&gt;Parsing partial dates can be a common challenge in Java programming, and Java 8's Instant API offers a powerful solution. &lt;br&gt;
In this article, we will explore how to effectively parse partial dates using Java 8's Instant API.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why (or not) parse partial dates?
&lt;/h2&gt;

&lt;p&gt;Isn't parsing "full" dates enough of a headache?&lt;br&gt;
Well, in a perfect world, we may handle only fully written dates and possibly in &lt;a href="https://it.wikipedia.org/wiki/ISO_8601" rel="noopener noreferrer"&gt;ISO 8601&lt;/a&gt; but the reality is that neither users nor our data sources do it. Think about it: how many times did you require a user to fill, for example, its birthday with a precision up to a millisecond? I guess never &lt;sup id="fnref1"&gt;1&lt;/sup&gt;.&lt;/p&gt;

&lt;p&gt;There are, of course, many ways to tackle this problem,&lt;br&gt;
one of the easiest being storing the date information with the exact resolution you need, so the omitted part becomes irrelevant.&lt;br&gt;
That's why in the database world we have many ways to store date/time information; for example, let's check Postgres:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;TIMESTAMP&lt;/code&gt;: the standard timestamp (timezone is assumed to be UTC)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TIMESTAMPZ&lt;/code&gt;: stores the timestamp along with timezone information&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DATE&lt;/code&gt;: only stores date (e.g. 2023-12-31)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TIME&lt;/code&gt;: only stores time (e.g. 23:59.999)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TIMEZ&lt;/code&gt;: only stores time along with timezone information&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So, before over-complicating your parsing logic you should ask yourself: "Is the omitted part in the date/time I'm going to parse relevant? Should it be stored?"&lt;/p&gt;
&lt;h2&gt;
  
  
  A more practical example
&lt;/h2&gt;

&lt;p&gt;It wouldn't be a complete article if I didn't provide a real-world scenario that I implemented recently.&lt;/p&gt;

&lt;p&gt;I was working on a CLI tool that along with other features, can query a database where there are two &lt;strong&gt;timestamps&lt;/strong&gt; columns defined: &lt;code&gt;start_timestamp&lt;/code&gt; and &lt;code&gt;end_timestamp&lt;/code&gt; that are used to record when a job starts and ends.&lt;/p&gt;

&lt;p&gt;The CLI has two arguments to filter over those two columns: &lt;code&gt;--from&lt;/code&gt; and &lt;code&gt;--to&lt;/code&gt; both of them accepting a date formatted as &lt;code&gt;yyyyMMdd[-HHmmss]&lt;/code&gt;.&lt;br&gt;&lt;br&gt;
Do you notice there's a part between square brackets? That's the optional part.&lt;/p&gt;

&lt;p&gt;The idea of the optional part is that, without adding more arguments to the CLI, a user can query one or more full days if it provides the two arguments with only the mandatory part, or can go more fine-grained if needed (even mixing the formats).&lt;/p&gt;

&lt;p&gt;To do it, we have two different argument semantics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--from&lt;/code&gt;: if omitted, the time part should be set to the start of the day which is "all zeroes"&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--to&lt;/code&gt;: if omitted it should be set to the end of the day which is &lt;code&gt;23:59:59.999&lt;/code&gt; (Here I haven't used the same format just for clarity)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Different API, different defaults
&lt;/h2&gt;

&lt;p&gt;Before getting to the actual code that solves our problem, let's write down some basic instructions to parse and print dates with Instant and the old Date APIs.&lt;/p&gt;
&lt;h4&gt;
  
  
  Old Date class
&lt;/h4&gt;

&lt;p&gt;Execute this snippet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;rawDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"20231231"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="nc"&gt;Date&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SimpleDateFormat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"yyyyMMdd"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawDate&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;it will output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Date&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;Sun&lt;/span&gt; &lt;span class="nc"&gt;Dec&lt;/span&gt; &lt;span class="mi"&gt;31&lt;/span&gt; &lt;span class="mo"&gt;00&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mo"&gt;00&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mo"&gt;00&lt;/span&gt; &lt;span class="no"&gt;CET&lt;/span&gt; &lt;span class="mi"&gt;2023&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first thing to notice is that the old Date class is already defaulting to "all zeroes". &lt;br&gt;
The second thing is that the output is in &lt;code&gt;CET&lt;/code&gt; timezone; this is machine-dependent.&lt;br&gt;&lt;br&gt;
Also, notice there is no explicit "default" concept.&lt;/p&gt;
&lt;h4&gt;
  
  
  Instant class, attempt #1
&lt;/h4&gt;

&lt;p&gt;Execute this snippet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;rawDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"20231231"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;instant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DateTimeFormatter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofPattern&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"yyyyMMdd"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawDate&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;Instant:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;it will give you an exception:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Text '20231231' could not be parsed: Unable to obtain Instant from TemporalAccessor: {},ISO resolved to 2023-12-31 of type java.time.format.Parsed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why does it fail?&lt;br&gt;&lt;br&gt;
Instant doesn't apply any default implicitly and the string &lt;code&gt;20231231&lt;/code&gt; only contains information about a date. It doesn’t contain any information about the time of day. As such, there's no sufficient information to create an instance of the Instant class.&lt;/p&gt;
&lt;h4&gt;
  
  
  Instant class, attempt #2
&lt;/h4&gt;

&lt;p&gt;Now that we know that Instant class wants the parser to explicitly declare defaults, let's execute this code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;rawDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"20231231"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;instant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DateTimeFormatterBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;appendPattern&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"yyyyMMdd"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parseDefaulting&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChronoField&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NANO_OF_DAY&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toFormatter&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withZone&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ZoneId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UTC"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawDate&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;Instant:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Instant is "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;instant&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This time it will give you the expected result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Instant is 2023-12-31T00:00:00Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;parseDefaulting&lt;/code&gt; takes a &lt;code&gt;ChronoField&lt;/code&gt; and a value.
In this example, I've hard-coded a &lt;code&gt;0&lt;/code&gt;, but you should use some meaningful constant; I like to use &lt;a href="https://docs.oracle.com/javase/8/docs/api/java/time/LocalTime.html" rel="noopener noreferrer"&gt;LocalTime&lt;/a&gt; for this.&lt;/li&gt;
&lt;li&gt;I also added an explicit timezone (UTC), try to omit it and compare the output (assuming you’re in a different timezone than UTC)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is almost everything we need to finally parse dates with optional parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instant class with optional parts
&lt;/h2&gt;

&lt;p&gt;With all the information that we have now, it's almost effortless:&lt;br&gt;
all we need is to define the optional part in the pattern string and play with &lt;code&gt;parseDefaulting&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's what I've come up with to handle the different semantics of &lt;code&gt;--from&lt;/code&gt; and &lt;code&gt;--to&lt;/code&gt; in my use case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.hypertesto.example&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.text.ParseException&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.Instant&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.LocalTime&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.ZoneId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.format.DateTimeFormatterBuilder&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.temporal.ChronoField&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InstantWithOptionalPartExample&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

        &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;ParseException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;rawPartialDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"20231231"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;rawFullDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"20231231-121212"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

        &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;partialFrom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parseFromArgument&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawPartialDate&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;partialTo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parseToArgument&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawPartialDate&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;fullFrom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parseFromArgument&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawFullDate&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;fullTo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parseToArgument&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawFullDate&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Partial 'from' argument is "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;partialFrom&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Partial 'to' argument is "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;partialTo&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Full 'from' argument is "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;fullFrom&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Full 'to' argument is "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;fullTo&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="nf"&gt;parseFromArgument&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseDateWithOptionalPart&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;LocalTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MIN&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="nf"&gt;parseToArgument&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseDateWithOptionalPart&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;LocalTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MAX&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="nf"&gt;parseDateWithOptionalPart&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;LocalTime&lt;/span&gt; &lt;span class="n"&gt;defaultTime&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DateTimeFormatterBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;appendPattern&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"yyyyMMdd[-HHmmss]"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parseDefaulting&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChronoField&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HOUR_OF_DAY&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;defaultTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getHour&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parseDefaulting&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChronoField&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MINUTE_OF_HOUR&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;defaultTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMinute&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parseDefaulting&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChronoField&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SECOND_OF_MINUTE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;defaultTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSecond&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parseDefaulting&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChronoField&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NANO_OF_SECOND&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;defaultTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getNano&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toFormatter&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withZone&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ZoneId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UTC"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;Instant:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I've arranged this example class to be self-explaining, but if you have doubts leave a comment; I'll be happy to clarify!&lt;/p&gt;

&lt;p&gt;This is its output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Partial 'from' argument is 2023-12-31T00:00:00Z
Partial 'to' argument is 2023-12-31T23:59:59.999999999Z
Full 'from' argument is 2023-12-31T12:12:12Z
Full 'to' argument is 2023-12-31T12:12:12.999999999Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;In conclusion, this article has briefly explored the utility and methodology of parsing partial dates using Java 8's Instant API.&lt;br&gt;
Through practical examples, we've seen how parsing partial dates with Instant API is straightforward, provided we have a clean idea of how to handle defaults. &lt;/p&gt;

&lt;p&gt;Lastly, remember there isn't only Instant API in modern Java, in fact,&lt;br&gt;
there are &lt;code&gt;LocalDateTime&lt;/code&gt; and &lt;code&gt;ZonedDateTime&lt;/code&gt;classes which are also fine for this kind of processing &lt;sup id="fnref2"&gt;2&lt;/sup&gt;.&lt;/p&gt;

&lt;p&gt;I hope the article was useful to you, thanks for reading it!&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;It would be impractical anyway, but I couldn't come up with a better example.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;Maybe better for my use case, but I had my reasons to stick with Instant class for that project.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>programming</category>
      <category>java</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>One login to rule them all: centralized auth for internal tools with Caddy</title>
      <dc:creator>Enrico Testori</dc:creator>
      <pubDate>Mon, 20 Jul 2026 20:55:42 +0000</pubDate>
      <link>https://dev.to/hypertesto/one-login-to-rule-them-all-centralized-auth-for-internal-tools-with-caddy-3fof</link>
      <guid>https://dev.to/hypertesto/one-login-to-rule-them-all-centralized-auth-for-internal-tools-with-caddy-3fof</guid>
      <description>&lt;p&gt;I am lazy. Not in the &lt;em&gt;"I don't want to work"&lt;/em&gt; sense&lt;sup id="fnref1"&gt;1&lt;/sup&gt;, but in the &lt;em&gt;"I refuse to solve the same problem twice"&lt;/em&gt; sense. &lt;br&gt;
So when I found myself looking at a growing collection of internal tools (each one either completely unprotected or with its own half-baked auth bolted on) I decided it was time to stop and fix it properly.&lt;/p&gt;

&lt;p&gt;The dream: one login, backed by the identity provider the company already uses, and every new tool just gets two lines in a config file. No user databases to maintain, no password reset flows to implement, no &lt;em&gt;"hey can you add my colleague to the thing"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;In our case the company uses Azure AD, so the plan was to put everything behind Caddy and add OAuth2 authentication against the existing Microsoft tenant. This post is about how that works and the two things that needed some extra attention to get right.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Caddy
&lt;/h2&gt;

&lt;p&gt;I've been through the nginx phase. The Apache phase. The &lt;em&gt;"let me just write a quick script to renew certificates"&lt;/em&gt; phase. &lt;br&gt;
At some point you just want something that works without making you feel like you're filing paperwork.&lt;/p&gt;

&lt;p&gt;Caddy hits a sweet spot I haven't found elsewhere: it's simple enough that you can read the whole config and understand it in one sitting, &lt;br&gt;
but complete enough that you rarely need to reach for anything else. The Caddyfile syntax is human-readable by design, and the defaults are sane, &lt;br&gt;
which sounds like a low bar until you've spent an afternoon debugging a cipher suite.&lt;/p&gt;

&lt;p&gt;The thing that genuinely removes the most friction though is automatic HTTPS. Point a domain at your server, add it to the Caddyfile, &lt;br&gt;
and Caddy handles the Let's Encrypt certificate negotiation, renewal, and everything in between. No certbot cron jobs, no &lt;code&gt;--nginx&lt;/code&gt; flags, &lt;br&gt;
no "oh the cert expired over the weekend" incidents. It just works, and it keeps working.&lt;sup id="fnref2"&gt;2&lt;/sup&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The pieces
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://caddyserver.com/" rel="noopener noreferrer"&gt;Caddy&lt;/a&gt; as reverse proxy&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/greenpau/caddy-security" rel="noopener noreferrer"&gt;Caddy-security&lt;/a&gt; (also known as AuthCrunch) for authentication and authorization. It plugs directly into Caddy and adds an authentication portal with OAuth2/OIDC support.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;Caddy supports plugins that get compiled directly into the binary. The canonical way to do this is &lt;code&gt;xcaddy&lt;/code&gt;, a build tool that fetches &lt;br&gt;
the plugins you want and compiles everything from source. It works, but it requires a Go toolchain on the server, the build takes a &lt;br&gt;
few minutes, and, going back to the laziness point, it's one more thing to maintain.&lt;/p&gt;

&lt;p&gt;The alternative is the official download page on the Caddy website. Select your platform, tick the plugins you want, and it hands you a &lt;br&gt;
pre-built binary. That's it. No Go installation, no waiting, no fuss.&lt;/p&gt;

&lt;p&gt;Head to the download page, select Linux amd64 (or your platform), add github.com/greenpau/caddy-security, and download. Then install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo install&lt;/span&gt; /path/to/download /usr/bin/caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On SELinux-enabled systems (Fedora, RHEL and friends) you also need to label the binary correctly, otherwise systemd will refuse to run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;setcap &lt;span class="s1"&gt;'cap_net_bind_service=+ep'&lt;/span&gt; /usr/bin/caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Setting up the service
&lt;/h2&gt;

&lt;p&gt;Caddy doesn't run as root. The recommended approach is a dedicated system user with no login shell and a home directory where Caddy &lt;br&gt;
can store certificates and other state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;groupadd &lt;span class="nt"&gt;--system&lt;/span&gt; caddy

&lt;span class="nb"&gt;sudo &lt;/span&gt;useradd &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--gid&lt;/span&gt; caddy &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--create-home&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--home-dir&lt;/span&gt; /var/lib/caddy &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--shell&lt;/span&gt; /usr/sbin/nologin &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"Caddy web server"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the config directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/caddy
&lt;span class="nb"&gt;sudo touch&lt;/span&gt; /etc/caddy/Caddyfile
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; caddy:caddy /etc/caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The official Caddy repository ships a ready-made systemd unit file, so grab that rather than writing one from scratch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-o&lt;/span&gt; /etc/systemd/system/caddy.service &lt;span class="se"&gt;\&lt;/span&gt;
    https://raw.githubusercontent.com/caddyserver/dist/master/init/caddy.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relevant bits of the unit file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;Service]
&lt;span class="nv"&gt;User&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;caddy
&lt;span class="nv"&gt;Group&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;caddy
&lt;span class="nv"&gt;ExecStart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin/caddy run &lt;span class="nt"&gt;--environ&lt;/span&gt; &lt;span class="nt"&gt;--config&lt;/span&gt; /etc/caddy/Caddyfile
&lt;span class="nv"&gt;ExecReload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin/caddy reload &lt;span class="nt"&gt;--config&lt;/span&gt; /etc/caddy/Caddyfile &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ExecReload&lt;/code&gt; is worth noting: &lt;code&gt;systemctl reload caddy&lt;/code&gt; does a graceful config reload without dropping connections.&lt;br&gt;
You'll use this constantly while tweaking the Caddyfile.&lt;/p&gt;

&lt;p&gt;Enable and start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; caddy
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Logs go through journald:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; caddy &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How caddy-security works
&lt;/h2&gt;

&lt;p&gt;The plugin introduces three concepts that work together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identity provider&lt;/strong&gt;: where users actually authenticate. In our case Azure AD via OAuth2, but it works with any OIDC-compliant 
provider or even a local user database if you need a fallback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication portal&lt;/strong&gt;: the login page. It ties together one or more identity providers, manages session cookies, and handles 
the OAuth2 callback. It lives on a dedicated subdomain (e.g. &lt;code&gt;auth.yourdomain.com&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorization policy&lt;/strong&gt;: the bouncer. Applied per-service, it checks whether the current user has the required roles and redirects to the portal if not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Azure AD setup
&lt;/h2&gt;

&lt;p&gt;Before touching the Caddyfile, register an application in the Azure Portal:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Azure Active Directory → App registrations → New registration&lt;/li&gt;
&lt;li&gt;Name: anything sensible&lt;/li&gt;
&lt;li&gt;Supported account types: Accounts in this organizational directory only &lt;/li&gt;
&lt;li&gt;Redirect URI (Web): &lt;code&gt;https://auth.yourdomain.com/oauth2/azure/authorization-code-callback&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once created, note down the Application (client) ID and Directory (tenant) ID from the Overview page. Then go to Certificates &amp;amp; secrets → New client secret &lt;br&gt;
and copy the Value immediately; &lt;strong&gt;it's only shown once&lt;/strong&gt;.&lt;sup id="fnref3"&gt;3&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;Under API permissions add the Microsoft Graph delegated permissions: &lt;code&gt;openid&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;profile&lt;/code&gt;, &lt;code&gt;User.Read&lt;/code&gt;, and grant admin consent.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Caddyfile
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;{&lt;/span&gt;
    email your@email.com
    order authenticate before respond
    order authorize before reverse_proxy

    security &lt;span class="o"&gt;{&lt;/span&gt;
        oauth identity provider azure &lt;span class="o"&gt;{&lt;/span&gt;
            realm azure
            driver azure
            client_id YOUR_CLIENT_ID
            client_secret YOUR_CLIENT_SECRET
            tenant_id YOUR_TENANT_ID
            scopes openid email profile
            &lt;span class="nb"&gt;enable logout&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        authentication portal myportal &lt;span class="o"&gt;{&lt;/span&gt;
            crypto default token lifetime 28800
            crypto key sign-verify YOUR_JWT_SECRET
            &lt;span class="nb"&gt;enable &lt;/span&gt;identity provider azure
            cookie domain yourdomain.com
            cookie path /
            cookie lifetime 28800
            cookie samesite lax
            cookie guess domain
            trust login redirect uri domain suffix yourdomain.com path prefix /

            ui &lt;span class="o"&gt;{&lt;/span&gt;
                links &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="s2"&gt;"My App"&lt;/span&gt; https://myapp.yourdomain.com
                &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        authorization policy protected_services &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;set &lt;/span&gt;auth url https://auth.yourdomain.com/login
            &lt;span class="nb"&gt;enable &lt;/span&gt;login hint
            allow roles authp/guest user admin
            crypto key verify YOUR_JWT_SECRET
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

auth.yourdomain.com &lt;span class="o"&gt;{&lt;/span&gt;
    authenticate with myportal
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# Protected&lt;/span&gt;
myapp.yourdomain.com &lt;span class="o"&gt;{&lt;/span&gt;
    authorize with protected_services
    reverse_proxy 10.0.0.2:8080
&lt;span class="o"&gt;}&lt;/span&gt;

anothertool.yourdomain.com &lt;span class="o"&gt;{&lt;/span&gt;
    authorize with protected_services
    reverse_proxy 10.0.0.3:3000
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Generate the JWT secret with &lt;code&gt;openssl rand -base64 32&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Two things that needed extra attention
&lt;/h3&gt;
&lt;h4&gt;
  
  
  Role remapping is broken in recent builds
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;caddy-security&lt;/code&gt; has a transform user directive that lets you promote authenticated users to named roles based on their email domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;transform user &lt;span class="o"&gt;{&lt;/span&gt;
    suffix match email @yourdomain.com
    action add role user
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In theory this is the right tool: map everyone from your organization to the user role, restrict the authorization policy to allow roles &lt;br&gt;
user admin, and anyone outside your tenant gets nothing even if they somehow authenticated. Clean and explicit.&lt;/p&gt;

&lt;p&gt;In practice, as of the current build, the transform doesn't reliably fire after an OAuth2 login. Users end up with only the default &lt;br&gt;
authp/guest role regardless of what the transform says, which means a policy that only allows user will lock everyone out; including &lt;br&gt;
the people you just spent an afternoon trying to let in.&lt;/p&gt;

&lt;p&gt;The workaround is to allow authp/guest in the authorization policy. It's not as granular as role mapping, but given that the OAuth2 &lt;br&gt;
provider is already scoped to a single tenant, any user who successfully authenticates is implicitly trusted, the gatekeeping is happening &lt;br&gt;
at the Azure level anyway.&lt;/p&gt;

&lt;p&gt;This does mean that in practice everyone in the same Azure AD tenant gets access to all protected services. For us that's fine: we're a small &lt;br&gt;
team and internal tools are, well, internal. But the building blocks for something more structured are already there: once transform user is &lt;br&gt;
reliable again, you can map roles per email domain, per specific user, or combine multiple policies to restrict individual services to specific &lt;br&gt;
groups. The concept scales, even if the current implementation needs a nudge.&lt;/p&gt;

&lt;p&gt;Keep an eye on the &lt;a href="https://github.com/greenpau/caddy-security/issues" rel="noopener noreferrer"&gt;issue tracker&lt;/a&gt; if fine-grained role mapping matters for your use case.&lt;/p&gt;
&lt;h4&gt;
  
  
  Cookies and post-login redirects
&lt;/h4&gt;

&lt;p&gt;Two things need to be correct for the flow to work end to end.&lt;/p&gt;

&lt;p&gt;The first is cookie scoping. After login, the browser needs to send the access token cookie to all protected subdomains, not just &lt;code&gt;auth.yourdomain.com&lt;/code&gt;. &lt;br&gt;
&lt;code&gt;cookie guess domain&lt;/code&gt; handles this automatically. Without it, every request to a protected service finds no token and loops back to the login page indefinitely &lt;br&gt;
(which is as fun as it sounds).&lt;/p&gt;

&lt;p&gt;A counterintuitive note from the official docs: don't use a leading dot (&lt;code&gt;.yourdomain.com&lt;/code&gt;) for cookie domain. Despite being standard practice in most contexts, &lt;br&gt;
the plugin doesn't support it and things break silently.&lt;/p&gt;

&lt;p&gt;The second is the post-login redirect. After a successful login you'd expect to land on the page you were originally trying to reach. Without the right &lt;br&gt;
configuration you land on the portal homepage instead, which is &lt;em&gt;anti-climactic&lt;/em&gt;. Two things are needed in combination: &lt;code&gt;set auth url&lt;/code&gt; must point to &lt;code&gt;/login&lt;/code&gt; &lt;br&gt;
explicitly (not just the root domain), and the portal needs to know which redirect destinations are trusted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;trust login redirect uri domain suffix yourdomain.com path prefix /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this the portal ignores the &lt;code&gt;redirect_url&lt;/code&gt; query parameter entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The final result
&lt;/h2&gt;

&lt;p&gt;The next time someone asks for a new internal tool, the answer is: spin it up, add two lines to the Caddyfile, reload. &lt;br&gt;
No user database, no password reset flow; &lt;em&gt;nothing&lt;/em&gt;.&lt;br&gt;&lt;br&gt;
Everyone with a company account can log in, everyone else can't.  &lt;/p&gt;

&lt;p&gt;I also added &lt;code&gt;enable login hint&lt;/code&gt; so in case anyone needs to login after the previous session expired, it will be presented with the Azure AD login page already filled&lt;br&gt;
with the account of the expired session: easy and effecient.&lt;/p&gt;

&lt;p&gt;My laziness form is now complete. Larry Wall would be proud.&lt;sup id="fnref4"&gt;4&lt;/sup&gt;&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;Well, not &lt;em&gt;only&lt;/em&gt; in that sense.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;Caddy also gets an A+ on SSL Labs out of the box, in case you needed another reason.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;There are two columns: Secret ID (a GUID) and Value (a long random string). You want the Value. Using the ID will give you a cryptic &lt;code&gt;AADSTS7000215&lt;/code&gt; error and a solid fifteen minutes of confusion.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn4"&gt;
&lt;p&gt;Larry Wall, creator of Perl, listed the three virtues of a programmer as: Laziness, Impatience, and Hubris. Laziness is first. We are vindicated&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>authentication</category>
      <category>oauth</category>
    </item>
    <item>
      <title>Adding a custom DeepSeek model in Junie CLI (No YAML demons required)</title>
      <dc:creator>Enrico Testori</dc:creator>
      <pubDate>Mon, 20 Jul 2026 12:11:48 +0000</pubDate>
      <link>https://dev.to/hypertesto/adding-a-custom-deepseek-model-in-junie-cli-no-yaml-demons-required-3em2</link>
      <guid>https://dev.to/hypertesto/adding-a-custom-deepseek-model-in-junie-cli-no-yaml-demons-required-3em2</guid>
      <description>&lt;p&gt;I switched my Junie CLI default model to DeepSeek last month. The reason wasn't philosophical: Claude and GPT are great, but they cost real money when you're hammering a CLI for hours. DeepSeek is cheaper. Sometimes the best technical decision is the one your wallet makes for you.&lt;/p&gt;

&lt;p&gt;The switch itself was anticlimactic. There's no &lt;code&gt;junie model add&lt;/code&gt; wizard, no guided setup, and the &lt;code&gt;--help&lt;/code&gt; output mentions custom models only in passing with a &lt;code&gt;--model&lt;/code&gt; flag that reads like an afterthought. What you actually do is drop a JSON file into &lt;code&gt;~/.junie/models/&lt;/code&gt; and… that's it. Junie picks it up.&lt;/p&gt;

&lt;p&gt;This post documents that process: adding &lt;code&gt;deepseek-v4-pro&lt;/code&gt; to Junie CLI, using the actual config format running on my machine right now.&lt;sup id="fnref1"&gt;1&lt;/sup&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother?
&lt;/h2&gt;

&lt;p&gt;Before we touch any config, let's answer the obvious: &lt;em&gt;why not just use the built-in providers?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Because in real life we optimize for different things at different times:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You want…&lt;/th&gt;
&lt;th&gt;So you reach for…&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Latency minimization&lt;/td&gt;
&lt;td&gt;a flash/fast variant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deep refactors with context&lt;/td&gt;
&lt;td&gt;a large-context model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost control on long sessions&lt;/td&gt;
&lt;td&gt;a cheaper provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider redundancy&lt;/td&gt;
&lt;td&gt;a backup endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Experimentation&lt;/td&gt;
&lt;td&gt;whatever just dropped on HuggingFace&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your tooling lets you swap models cleanly, you gain &lt;strong&gt;operational sanity&lt;/strong&gt;. And operational sanity is underrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Junie CLI installed.&lt;/strong&gt; You should already have &lt;code&gt;junie&lt;/code&gt; on your &lt;code&gt;PATH&lt;/code&gt;. Verify:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   junie &lt;span class="nt"&gt;--help&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A DeepSeek API key.&lt;/strong&gt; Get one from &lt;a href="https://platform.deepseek.com/" rel="noopener noreferrer"&gt;platform.deepseek.com&lt;/a&gt;. You'll need to top up credits.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Basic JSON literacy.&lt;/strong&gt; No YAML, no TOML, no arcane DSL. Just one JSON file. You've got this.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How Junie CLI discovers custom models
&lt;/h2&gt;

&lt;p&gt;Junie looks for model definitions in &lt;strong&gt;two default locations&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;~/.junie/models/&lt;/code&gt;: global, available to all projects&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;project&amp;gt;/.junie/models/&lt;/code&gt;: per-project overrides&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each model is a single &lt;code&gt;.json&lt;/code&gt; file. The filename becomes the model's identifier within Junie. So &lt;code&gt;~/.junie/models/deepseek.json&lt;/code&gt; registers a model called… &lt;code&gt;deepseek&lt;/code&gt;. Creative, I know.&lt;/p&gt;

&lt;p&gt;You can also point Junie at additional directories with &lt;code&gt;--model-location &amp;lt;path&amp;gt;&lt;/code&gt; if you prefer to keep your model configs in a dotfiles repo or shared volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Create the model JSON
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;~/.junie/models/deepseek.json&lt;/code&gt; (or &lt;code&gt;&amp;lt;project&amp;gt;/.junie/models/deepseek.json&lt;/code&gt; if you want it scoped to a single codebase):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deepseek-v4-pro"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"baseUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.deepseek.com/chat/completions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"apiType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OpenAICompletion"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"apiKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sk-your-deepseek-api-key-here"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"temperature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"primaryModel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deepseek-v4-pro"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fasterModel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deepseek-v4-flash"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's unpack what each field does:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The primary model identifier. Junie displays this in the model picker and passes it in API requests.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;baseUrl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The endpoint Junie POSTs to. DeepSeek's API is OpenAI-compatible, so we point it at their chat/completions endpoint.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apiType&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tells Junie which request/response format to use. &lt;code&gt;"OpenAICompletion"&lt;/code&gt; covers OpenAI, DeepSeek, and most compatible providers.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apiKey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your API key. &lt;strong&gt;Yes, it's plaintext in a JSON file.&lt;/strong&gt; We'll talk about this.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;temperature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Controls response randomness. &lt;code&gt;0&lt;/code&gt; means deterministic; bump it to &lt;code&gt;0.3&lt;/code&gt;–&lt;code&gt;0.7&lt;/code&gt; for more creative output.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;primaryModel.id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The model ID sent in the API request body. This must match the DeepSeek model name exactly.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fasterModel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;(Optional)&lt;/em&gt; A lighter/faster model for quick tasks like inline autocomplete or summarization.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  A stern word about &lt;code&gt;apiKey&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Yeah, it's sitting there in plaintext. I know. No, Junie doesn't currently read it from an environment variable for custom models&lt;sup id="fnref2"&gt;2&lt;/sup&gt;. Your options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Set restrictive file permissions:&lt;/strong&gt; &lt;code&gt;chmod 600 ~/.junie/models/deepseek.json&lt;/code&gt;. At least other users on the system can't read it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a LiteLLM proxy&lt;/strong&gt; as middleware (see the LiteLLM section below): this keeps the key out of Junie's config entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accept it and don't commit the file&lt;/strong&gt; to a public dotfiles repo. You're an adult.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For what it's worth, Junie stores the key in your home directory, which on a single-user development machine is about as secure as any other dotfile with secrets in it. Not ideal, but not the end of the world either&lt;sup id="fnref3"&gt;3&lt;/sup&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Use the model
&lt;/h2&gt;

&lt;p&gt;Once the JSON file is in place, you have three ways to invoke it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CLI flag (one-off)&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  junie &lt;span class="nt"&gt;--model&lt;/span&gt; custom:deepseek &lt;span class="s2"&gt;"Explain this function."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will start junie in interactive mode using the custom model defined previously in json configuration.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Interactive mode&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Launch Junie interactively, then pick the model from the selector (usually &lt;code&gt;/model&lt;/code&gt; or the equivalent model-switching shortcut):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  junie
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your custom &lt;code&gt;deepseek&lt;/code&gt; model should appear in the list alongside the built-in providers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Settings override (persistent default)
&lt;/h3&gt;

&lt;p&gt;Edit &lt;code&gt;~/.junie/settings.json&lt;/code&gt; and add or edit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"modelForLaunch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"custom:deepseek"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every session starts with DeepSeek unless you override with &lt;code&gt;--model&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3 — Verify it works
&lt;/h2&gt;

&lt;p&gt;Before trusting it with a 400-line refactor, run a quick sanity check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;junie &lt;span class="nt"&gt;--model&lt;/span&gt; custom:deepseek &lt;span class="s2"&gt;"Say hello and confirm which model you're running on."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the response comes back coherent and the logs show &lt;code&gt;deepseek-v4-pro&lt;/code&gt;, you're in business. If you get an auth error, double-check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The API key is correct and has credits&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;baseUrl&lt;/code&gt; ends with &lt;code&gt;/chat/completions&lt;/code&gt; (no trailing slash weirdness)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;primaryModel.id&lt;/code&gt; matches exactly what DeepSeek expects&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Junie CLI's custom model system is refreshingly straightforward: drop a JSON file in a directory, and you're done. No YAML anchors, no multi-file inheritance chains, no environment-variable indirection puzzles. Just a flat config that tells the CLI where to send requests and which model ID to use.&lt;/p&gt;

&lt;p&gt;Is the plaintext API key ideal? No. But with file permissions and/or a LiteLLM proxy, you can mitigate the concern without adding much friction. For single user dev-machines it's a worth tradeoff.&lt;/p&gt;

&lt;p&gt;Now go wire up your DeepSeek model and tell it to write you some code. Or a haiku about YAML. Your call.&lt;/p&gt;

&lt;p&gt;Thanks for reading!&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;If you're looking for the accompanying Italian version of this post… there isn't one. Some topics just work better in English, and CLI config files are &lt;em&gt;definitely&lt;/em&gt; in that camp. &lt;em&gt;"Drop un JSON nella cartella models"&lt;/em&gt; doesn't quite hit the same way.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;If you're reading this from the future and Junie &lt;em&gt;does&lt;/em&gt; support &lt;code&gt;apiKeyEnv&lt;/code&gt; or similar: congratulations, the timeline is better than mine. Please update this post.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;&lt;em&gt;"Not the end of the world"&lt;/em&gt; is the security posture equivalent of &lt;em&gt;"it compiles, ship it."&lt;/em&gt; Take appropriate precautions.&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>llm</category>
      <category>cli</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Balancing flexibility and strictness with lenient Duration deserialization in Kotlin</title>
      <dc:creator>Enrico Testori</dc:creator>
      <pubDate>Thu, 16 Jul 2026 10:41:53 +0000</pubDate>
      <link>https://dev.to/hypertesto/balancing-flexibility-and-strictness-with-lenient-duration-deserialization-in-kotlin-385p</link>
      <guid>https://dev.to/hypertesto/balancing-flexibility-and-strictness-with-lenient-duration-deserialization-in-kotlin-385p</guid>
      <description>&lt;p&gt;I have written before about Kotlin's &lt;code&gt;Duration&lt;/code&gt; class, and its blend of technical sophistication and developer friendliness continues to impress. Recently, a unit test highlighted an important distinction in how Kotlin handles duration parsing. The duration string &lt;code&gt;"1s"&lt;/code&gt; works perfectly with &lt;code&gt;Duration.parse()&lt;/code&gt;, but fails during JSON deserialization when dealing with real-world data. &lt;/p&gt;

&lt;p&gt;This apparent inconsistency is actually a deliberate design choice. The &lt;code&gt;Duration.parse()&lt;/code&gt; method is flexible by design, while &lt;code&gt;kotlinx.serialization&lt;/code&gt; is intentionally strict to enforce standardization.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens under the hood?
&lt;/h2&gt;

&lt;p&gt;Examining Kotlin's source code clarifies this behavior. First, let's look at the &lt;code&gt;Duration&lt;/code&gt; class itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In Duration.kt&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;inline&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;rawValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Comparable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="k"&gt;companion&lt;/span&gt; &lt;span class="k"&gt;object&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;parseDuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strictIso&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"Invalid duration string format: '$value'."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few key points stand out in this implementation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Duration&lt;/code&gt; is an inline class with an internal constructor.&lt;/li&gt;
&lt;li&gt;This constructor cannot be used for deserialization due to its internal visibility.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;parse()&lt;/code&gt; method intentionally accepts non-ISO formats by explicitly setting &lt;code&gt;strictIso = false&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now, let's look at how &lt;code&gt;kotlinx.serialization&lt;/code&gt; handles the &lt;code&gt;Duration&lt;/code&gt; class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;DurationSerializer&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;KSerializer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SerialDescriptor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;PrimitiveSerialDescriptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"kotlin.time.Duration"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PrimitiveKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Encoder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encodeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toIsoString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;deserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Decoder&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parseIsoString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decodeString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The serializer makes a deliberate choice to use &lt;code&gt;parseIsoString()&lt;/code&gt; for deserialization. Instead of relying on the more permissive &lt;code&gt;parse()&lt;/code&gt; method, it enforces the ISO-8601 standard for data integrity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing a lenient custom serializer
&lt;/h2&gt;

&lt;p&gt;Since we cannot change the built-in behavior of &lt;code&gt;kotlinx.serialization&lt;/code&gt;—and generally shouldn't, given the importance of enforcing standards in data exchange—we can create our own custom lenient serializer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;LenientDurationSerializer&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;KSerializer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SerialDescriptor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;PrimitiveSerialDescriptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"kotlin.time.Duration"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PrimitiveKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;deserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Decoder&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decodeString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;// Using the lenient parser&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Encoder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encodeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toIsoString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;// Still formal in output using ISO&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This serializer accepts casual input formats by using &lt;code&gt;parse()&lt;/code&gt;, but ensures formal ISO-8601 formatting on output by using &lt;code&gt;toIsoString()&lt;/code&gt;. You can apply it to your data classes using the &lt;code&gt;@Serializable&lt;/code&gt; annotation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;ProcessDuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;@Serializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LenientDurationSerializer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Now both these work:&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;formal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"""{"duration": "PT1S"}"""&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;casual&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"""{"duration": "1s"}"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach adheres to a fundamental principle of data exchange: be flexible in what you accept, but strict in what you produce. Watch how our duration gets standardized during serialization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;What&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;goes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;in&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1s"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;What&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;comes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;out&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"duration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT1S"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both strings represent the exact same duration, but the output is standardized. When writing data that might be consumed by other systems, enforcing an ISO format is a necessary practice for maintaining a reliable ecosystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Standards vs. reality
&lt;/h2&gt;

&lt;p&gt;While enforcing ISO-8601 in serialization is technically correct, real-world data is often much less organized. Anyone who has dealt with dates, times, and durations in production environments knows the variety of formats that can appear. &lt;/p&gt;

&lt;p&gt;In a typical production environment, you might encounter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Natural language timestamps&lt;/strong&gt; generated by NLP parsing services (e.g., "Last Monday").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inconsistent timezones&lt;/strong&gt; represented in various ways such as "GMT+1", "UTC+1", "+0100", or "Europe/Paris".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Varying date formats&lt;/strong&gt; originating from legacy systems (e.g., "23-11-2023", "11-23-2023", or "2023/11/23").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw millisecond values&lt;/strong&gt; typically found in Java integrations ("5400000ms").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excessively verbose XML-style durations&lt;/strong&gt; from older architectures ("P0Y0M0DT1H30M0S").&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The variety of time formats is a persistent challenge in software development. While standards like ISO-8601 are crucial for reliable data exchange, being overly rigid can cause integrations to fail. A pragmatic approach requires us to handle the data as it arrives, gracefully converting it into a standardized format for the future.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Dealing with rigid data formats can sometimes feel like &lt;em&gt;P100Y of ISO-litude&lt;/em&gt;&lt;sup id="fnref1"&gt;1&lt;/sup&gt;: an endless century of strict parsing errors and isolated systems. However, examining Kotlin's source code reveals the thoughtful design behind its time packages. &lt;/p&gt;

&lt;p&gt;By allowing &lt;code&gt;Duration.parse()&lt;/code&gt; to welcome various formats for developer convenience, while keeping &lt;code&gt;kotlinx.serialization&lt;/code&gt; anchored to ISO standards, the ecosystem strikes a perfect balance. You do not have to be isolated by strict formats; you simply need a custom serializer to bridge the gap between human-friendly inputs and machine-standardized outputs.&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;For the uninitiated: P100Y is the valid ISO-8601 duration string for exactly 100 years, with apologies to Gabriel García Márquez ;-)&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>programming</category>
      <category>kotlin</category>
      <category>serialization</category>
    </item>
    <item>
      <title>Fish, Foot and beyond: a terminal setup that finally smells right 🐟👣</title>
      <dc:creator>Enrico Testori</dc:creator>
      <pubDate>Wed, 15 Jul 2026 06:59:22 +0000</pubDate>
      <link>https://dev.to/hypertesto/fish-foot-and-beyond-a-terminal-setup-that-finally-smells-right-48k3</link>
      <guid>https://dev.to/hypertesto/fish-foot-and-beyond-a-terminal-setup-that-finally-smells-right-48k3</guid>
      <description>&lt;p&gt;Picture this: there I was, happily using Tilix for my terminal needs on Fedora.&lt;br&gt;
Split panes, dropdown terminal, the whole shebang. Life was good... until it wasn't.&lt;br&gt;
Recent Fedora updates started making Tilix throw tantrums, and suddenly my trusty terminal setup felt like that friend who keeps flaking on plans.&lt;/p&gt;

&lt;p&gt;After some soul-searching (and a lot of Reddit threads), I realized I needed to break down my terminal needs: a rock-solid Wayland-native terminal emulator, a shell that wouldn't make me hate scripting, and a multiplexer that could keep up with modern workflows.&lt;/p&gt;

&lt;p&gt;Enter the new trio: &lt;a href="https://codeberg.org/dnkl/foot" rel="noopener noreferrer"&gt;Foot&lt;/a&gt;, &lt;a href="https://fishshell.com/" rel="noopener noreferrer"&gt;Fish&lt;/a&gt;, and &lt;a href="https://zellij.dev/" rel="noopener noreferrer"&gt;Zellij&lt;/a&gt;. Yes, I know - between Fish and Foot, this setup sounds like it came from a very weird pet shop. But stick with me here!&lt;/p&gt;
&lt;h2&gt;
  
  
  Sometimes less is more
&lt;/h2&gt;

&lt;p&gt;For terminal emulation I wanted something simple, stable, and Wayland-native. Foot checked all these boxes and then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;is blazing fast thanks to being written in C&lt;/li&gt;
&lt;li&gt;is hardware accelerated rendering (hello, smooth scrolling!)&lt;/li&gt;
&lt;li&gt;has minimal dependencies (goodbye, random breakages!)&lt;/li&gt;
&lt;li&gt;has simple configuration in a single file&lt;/li&gt;
&lt;li&gt;has &lt;a href="https://en.wikipedia.org/wiki/Sixel" rel="noopener noreferrer"&gt;Sixel&lt;/a&gt; support for those fancy terminal graphics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now, I'll be honest - when people talked about "fast terminals," I used to roll my eyes. How fast does a terminal need to be to show some text? Well, turns out I was wonderfully wrong - you don't realize how much terminal lag affects your workflow until you try a faster one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Life's too short for boring shells!
&lt;/h2&gt;

&lt;p&gt;Remember the first time you saw someone's terminal autocomplete something magical? That's Fish, all day, every day; no hacky setups required.&lt;br&gt;
Here's what made me fall hook, line, and sinker for it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fish remembers your commands and suggests them as you type. It's like having a very eager assistant who actually knows what they're doing. Autosuggestions feel like mind-reading!&lt;/li&gt;
&lt;li&gt;Colors! So many colors! And they actually mean something (unlike my old prompt that I made pretty but couldn't remember what anything meant)&lt;/li&gt;
&lt;li&gt;No more &lt;code&gt;if [ -f something ]&lt;/code&gt; syntax that looks like it came from the 80s. Fish makes scripting feel like actual programming&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sure, there's the occasional &lt;em&gt;"this shell script isn't compatible with Fish"&lt;/em&gt; moment, but let's be real - when was the last time you looked at a bash script and thought "wow, this is so intuitive"? Fish makes the trade-off simple: give up some backwards compatibility, get a shell that feels like it was actually designed in this century. And for those rare occasions when you really need to run a bash just prefix it with &lt;code&gt;bash&lt;/code&gt; and move on with your life.&lt;/p&gt;
&lt;h2&gt;
  
  
  Getting on the right Foot
&lt;/h2&gt;

&lt;p&gt;First things first - let's get our Foot in the door (sorry, not sorry).&lt;br&gt;
On Fedora, it's as simple as this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install &lt;/span&gt;foot fish zellij
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Easy, isn't it?&lt;/p&gt;

&lt;p&gt;And of course, &lt;code&gt;CTRL-ALT-T&lt;/code&gt; still spawns my terminal - some habits are worth keeping.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjkocu5oohh8b99bytuhx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjkocu5oohh8b99bytuhx.png" title="Shortcut configured on Gnome" alt="Gnome Shortcut" width="722" height="662"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Foot configuration
&lt;/h3&gt;

&lt;p&gt;My Foot configuration is straightforward but purposeful. Let's break it down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[main]&lt;/span&gt;
&lt;span class="py"&gt;font&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;JetBrains Mono:size=16&lt;/span&gt;
&lt;span class="py"&gt;initial-window-mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;fullscreen&lt;/span&gt;
&lt;span class="py"&gt;shell&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/fish&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main section is minimal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JetBrains Mono because it's a fantastic monospace font with great readability&lt;/li&gt;
&lt;li&gt;terminals starts fullscreen because that's what I ended up doing as first action every time I opened a Tilix in my old setup&lt;/li&gt;
&lt;li&gt;last but not least, of course, Fish is set as the default shell because that's the whole point of this setup.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[colors]&lt;/span&gt;
&lt;span class="c"&gt;# A wild variety of Catppuccin Mocha colors...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While I'm unsure about the theme interaction with zellij, it's working fine, and so I'll briefly give an overview of it. I'm using Catppuccin Mocha - a soothing dark theme that's easy on the eyes. It provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A dark background (&lt;code&gt;1e1e2e&lt;/code&gt;) with light text (&lt;code&gt;cdd6f4&lt;/code&gt;) for good contrast&lt;/li&gt;
&lt;li&gt;Carefully chosen colors for different terminal elements&lt;/li&gt;
&lt;li&gt;Nice selection colors that don't make you squint (I am colorblind myself)&lt;/li&gt;
&lt;li&gt;Special colors for search and jump labels that actually stand out&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The URLs are highlighted in a gentle blue (&lt;code&gt;89b4fa&lt;/code&gt;) - because clickable links should be obvious but not screaming for attention.&lt;br&gt;
Nothing fancy, no transparency effects or padding tweaks - just a clean, functional setup that gets out of your way while being pleasant to look at.&lt;/p&gt;
&lt;h3&gt;
  
  
  Fish configuration
&lt;/h3&gt;

&lt;p&gt;My Fish config is minimalist but gets the job done. Let's see what's happening here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;set -x ZELLIJ_AUTO_EXIT true
if status is-interactive
    # Commands to run in interactive sessions can go here
    eval (zellij setup --generate-auto-start fish | string collect)
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;The first line sets up Zellij to automatically exit when the last pane closes (as easy as pressing &lt;code&gt;CTRL-D&lt;/code&gt;). It's a quality-of-life setting - when you're done with your last terminal window, Zellij closes cleanly instead of hanging around with an empty session.&lt;/li&gt;
&lt;li&gt;Then, we check if we're in an interactive shell (as opposed to running a script)&lt;/li&gt;
&lt;li&gt;Lastly, we auto-start Zellij using its built-in Fish integration. The string collect bit ensures all the output from the setup command is handled properly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result? Every time I open Foot, it automatically launches Fish, which automatically starts Zellij. No manual steps, no &lt;em&gt;"oops I forgot to start my multiplexer"&lt;/em&gt; moments - everything just flows.&lt;br&gt;
You know those tiny tasks that seem insignificant? &lt;em&gt;"Oh, it's just two keypresses to start Zellij..."&lt;/em&gt; But when you do them 20 times a day, those micro-interruptions add up and break your flow. Now my terminal setup just works, and I can focus on what I actually opened the terminal for in the first place.&lt;/p&gt;
&lt;h3&gt;
  
  
  Zellij configuration
&lt;/h3&gt;

&lt;p&gt;Nothing, it works out of the box! No question asked.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F31o3bpigi8hfj8o9ue4u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F31o3bpigi8hfj8o9ue4u.png" title="Zellij in action with 1 tab and 2 panels" alt="Zellij in action" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Out of the box, Zellij shows you what shortcuts you can use right in the status bar.&lt;br&gt;
It's a nice way to learn while you work, until your fingers start doing their thing automatically.&lt;/p&gt;
&lt;h3&gt;
  
  
  A small note about SSH
&lt;/h3&gt;

&lt;p&gt;Here's a little gotcha I discovered when working with remote servers. By default, Foot uses its own terminal type (&lt;code&gt;foot&lt;/code&gt; or &lt;code&gt;foot-direct&lt;/code&gt;), which is great for local use but can confuse some remote hosts. Adding this tiny bit to my &lt;code&gt;~/.ssh/config&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;Host&lt;/span&gt; *
&lt;span class="n"&gt;SetEnv&lt;/span&gt; &lt;span class="n"&gt;TERM&lt;/span&gt;=&lt;span class="n"&gt;xterm&lt;/span&gt;-&lt;span class="m"&gt;256&lt;/span&gt;&lt;span class="n"&gt;color&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells SSH to identify as the widely-supported &lt;code&gt;xterm-256color&lt;/code&gt; when connecting to remote machines.&lt;br&gt;
It's a simple fix that prevents those annoying "unknown terminal type" errors and weird formatting issues when SSHing into servers.&lt;br&gt;
Sure, you lose some of Foot's fancy features on remote connections, but it's a fair trade for having everything just work™ without&lt;br&gt;
having to install terminal definitions on every server you access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Future experiments: down the Zellij rabbit hole
&lt;/h2&gt;

&lt;p&gt;While this setup is already solid, I've got my eyes on some interesting Zellij possibilities. First up is the SSH client multiplexing - being able to manage multiple SSH connections in a single Zellij session, with each remote server neatly organized in its own space. Gone would be the days of juggling terminal windows trying to find that one production session.&lt;/p&gt;

&lt;p&gt;Zellij's plugin system caught my attention too. Since plugins can be written in any language that compiles to WebAssembly, I might try my hand at building something that scratches my own itches - maybe something to streamline my SSH session management or Git workflow. The project is still quite active, so there's room to play around.&lt;/p&gt;

&lt;p&gt;I also want to dig into Foot's session management. The idea is to have a single main Foot window that all new terminal instances connect to when I hit my terminal shortcut. Should be doable with Foot's &lt;code&gt;--server&lt;/code&gt; mode and some window manager tweaks. That way I could keep my workflow exactly the same but avoid the window clutter.&lt;/p&gt;

&lt;p&gt;These might be solutions looking for problems, but hey - isn't that half the fun of terminal customization? Plus, unlike my Tilix tweaking days, if something breaks, my base setup remains rock-solid. Stay tuned for potential updates if any of these experiments turn out particularly useful!&lt;/p&gt;

&lt;h2&gt;
  
  
  Final considerations
&lt;/h2&gt;

&lt;p&gt;After a few months with this setup, I can't imagine going back. The combination of Foot's speed, Fish's smarts, and Zellij's flexibility has genuinely improved my daily workflow. Sure, it took a bit of tweaking to get everything just right, but that's the beauty of it - each piece is simple enough that customizing doesn't feel like solving a puzzle.&lt;/p&gt;

&lt;p&gt;Looking back at my Tilix days, I realize sometimes "just works" beats "works with all the features." This setup is faster, more stable, and honestly, just more enjoyable to use. Plus, there's something satisfying about having a terminal setup that's both modern and respectful of system defaults. If you're on the fence about modernizing your terminal setup, consider this your sign to take the plunge - your future self will thank you.&lt;/p&gt;

&lt;p&gt;Oh, and here's a neat thing about this setup: it doesn't touch your system's default shell at all. Bash remains as the system shell, handling all its traditional duties, while Fish only kicks in when you're actually using the terminal interactively. It's like having your cake and eating it too: the system keeps humming along with bash while you get all the nice Fish goodies.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;P.S. No Fish were harmed in the making of this terminal setup, though some bad shell scripts were rightfully abandoned.&lt;sup id="fnref1"&gt;1&lt;/sup&gt;&lt;/em&gt;&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;I realized I haven't left any &lt;em&gt;foot&lt;/em&gt; notes in a post about Foot. Had to fix that!&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>linux</category>
      <category>productivity</category>
      <category>tooling</category>
      <category>bash</category>
    </item>
  </channel>
</rss>
