← LOGBOOK LOG-450
WORKING · SOFTWARE ·
HASKELLSHELLPROCESSESFILESYSTEMENVIRONMENT-VARIABLESIOFUNCTIONAL-PROGRAMMING

Process State: Working Directories and Environments

How pwd, cd, HOME, and PATH expose process-local state through Haskell's Directory and Environment APIs.

Every process carries a current working directory and an environment. A shell exposes and mutates that state with commands such as pwd and cd, while variables such as HOME and PATH connect user-facing syntax to filesystem locations.

The current directory is implicit process state

pwd reads the directory stored by the operating system for the shell process:

| "pwd" `isPrefixOf` command = do
    currentDirectory <- getCurrentDirectory
    putStrLn currentDirectory
    main

The type shows that this is not a fixed value derivable from arguments:

getCurrentDirectory :: IO FilePath

FilePath is a type synonym for String, so it improves meaning for human readers without creating a distinct compiler-enforced representation.

Relative paths are interpreted against this current directory. If the process is in /work/project, then src/Main.hs refers to /work/project/src/Main.hs. Changing the current directory therefore changes how later relative paths resolve.

cd mutates the parent shell

The first directory-changing branch checks the target before updating process state:

let target = drop 3 command
exists <- doesDirectoryExist target

if exists
  then setCurrentDirectory target
  else putStrLn ("cd: " ++ target ++ ": No such file or directory")

Both filesystem operations are explicit effects:

doesDirectoryExist  :: FilePath -> IO Bool
setCurrentDirectory :: FilePath -> IO ()

The Boolean check controls which action is constructed next. Haskell’s if is an expression, so both branches must have the same type. Here both branches are IO ():

setCurrentDirectory target :: IO ()
putStrLn message            :: IO ()

The update occurs in the shell process, so the next REPL iteration observes the new directory. Running an external cd process would change only the child’s directory and lose the result when the child exits.

Tilde expansion is not ordinary path handling

The filesystem does not generally interpret ~ as the home directory. Tilde expansion is shell syntax that must occur before filesystem functions receive the path.

The implementation handles the simplest complete case:

resolvedTarget <-
  if target == "~"
    then getEnv "HOME"
    else pure target

The two branches must again have the same type. getEnv "HOME" has type IO String, while target is only a String. pure target lifts the unchanged value into IO so both alternatives produce IO String.

The resolved path, rather than the original token, must be used consistently:

exists <- doesDirectoryExist resolvedTarget

if exists
  then setCurrentDirectory resolvedTarget
  else putStrLn ("cd: " ++ target ++ ": No such file or directory")

The original target remains useful in the error because it matches what was entered. The resolved target is the actual filesystem operand.

This implementation expands only an argument equal to ~. Forms such as ~/code require prefix-aware expansion, and ~otheruser requires account database lookup rather than simply reading HOME.

Environment lookup can fail

The type of getEnv does not expose absence:

getEnv :: String -> IO String

If the variable is missing, it raises an I/O exception. lookupEnv makes the optional result explicit:

lookupEnv :: String -> IO (Maybe String)

A bounded home-directory resolver can therefore represent a missing HOME without an exception-driven normal path:

resolveHome :: FilePath -> IO (Maybe FilePath)
resolveHome "~" = lookupEnv "HOME"
resolveHome target = pure (Just target)

The additional Maybe forces the caller to choose an error policy. This is the same modelling principle used by executable lookup: expected absence belongs in the return type.

PATH is environment state too

PATH is read from the same process environment:

path <- getEnv "PATH"
let directories = splitSearchPath path

The shell inherits its initial environment from its parent process, and external children inherit the shell environment unless the launch API overrides it. Current directory and environment therefore form part of the execution context passed through the process tree.

They differ in one important way from ordinary immutable Haskell values. setCurrentDirectory changes global process state, so the result of a later getCurrentDirectory depends on action order. IO preserves that sequencing in the program’s type and structure.

State suggests a future shell model

As more state appears—previous directory, exported variables, exit status, jobs—calling global process APIs throughout the dispatcher becomes harder to reason about. A later design can make the shell-owned portion explicit:

data ShellState = ShellState
  { lastExitCode :: Int
  , previousDirectory :: Maybe FilePath
  }

Operating-system state still requires IO, but explicit application state can be passed between iterations. The architecture then distinguishes pure state transitions, process-global effects, and child-process execution instead of treating all mutable context as one undifferentiated shell loop.